Implement event/date search bar
This commit is contained in:
parent
9183ffe873
commit
45939939f2
@ -161,7 +161,27 @@ function measureFromProbe() {
|
||||
}
|
||||
}
|
||||
|
||||
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
||||
const {
|
||||
getWeekIndex,
|
||||
getFirstDayForVirtualWeek,
|
||||
goToToday,
|
||||
handleHeaderYearChange,
|
||||
scrollToWeekCentered,
|
||||
} = vwm
|
||||
|
||||
// Reference date for search: center of the current viewport (virtual week at vertical midpoint)
|
||||
const centerVisibleWeek = computed(() => {
|
||||
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
|
||||
return Math.floor(midRow) + minVirtualWeek.value
|
||||
})
|
||||
const centerVisibleDateStr = computed(() => {
|
||||
try {
|
||||
const d = getFirstDayForVirtualWeek(centerVisibleWeek.value)
|
||||
return toLocalString(d, DEFAULT_TZ)
|
||||
} catch {
|
||||
return calendarStore.today
|
||||
}
|
||||
})
|
||||
|
||||
// createWeek logic moved to virtualWeeks plugin
|
||||
|
||||
@ -397,155 +417,25 @@ const handleEventClick = (payload) => {
|
||||
openEditEventDialog(payload)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Event Search (Ctrl/Cmd+F)
|
||||
// ------------------------------
|
||||
const searchOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([]) // [{ id, title, startDate }]
|
||||
const searchIndex = ref(0)
|
||||
const searchInputRef = ref(null)
|
||||
|
||||
function isEditableElement(el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function buildSearchResults() {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) {
|
||||
searchResults.value = []
|
||||
searchIndex.value = 0
|
||||
return
|
||||
}
|
||||
const out = []
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
const title = (ev.title || '').trim()
|
||||
if (!title) continue
|
||||
if (title.toLowerCase().includes(q)) {
|
||||
out.push({ id: ev.id, title: title, startDate: ev.startDate })
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
||||
searchResults.value = out
|
||||
if (searchIndex.value >= out.length) searchIndex.value = 0
|
||||
}
|
||||
|
||||
watch(searchQuery, buildSearchResults)
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
if (searchOpen.value && searchQuery.value.trim()) buildSearchResults()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function openSearch(prefill = '') {
|
||||
searchOpen.value = true
|
||||
if (prefill) searchQuery.value = prefill
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
buildSearchResults()
|
||||
}
|
||||
function closeSearch() {
|
||||
searchOpen.value = false
|
||||
}
|
||||
function navigateSearch(delta) {
|
||||
const n = searchResults.value.length
|
||||
if (!n) return
|
||||
searchIndex.value = (searchIndex.value + delta + n) % n
|
||||
scrollToCurrentResult()
|
||||
}
|
||||
function scrollToCurrentResult() {
|
||||
const cur = searchResults.value[searchIndex.value]
|
||||
if (!cur) return
|
||||
// Scroll so week containing event is near top (offset 2 weeks for context)
|
||||
function scrollToEventStart(startDate, smooth = true) {
|
||||
try {
|
||||
const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ)
|
||||
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
|
||||
const weekIndex = getWeekIndex(dateObj)
|
||||
const offsetWeeks = 2
|
||||
const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks)
|
||||
const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScrollTop, 'search-jump')
|
||||
scheduleWindowUpdate('search-jump')
|
||||
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
|
||||
} catch {}
|
||||
}
|
||||
function activateCurrentResult() {
|
||||
scrollToCurrentResult()
|
||||
function handleHeaderSearchPreview(result) {
|
||||
if (!result) return
|
||||
scrollToEventStart(result.startDate, true)
|
||||
}
|
||||
|
||||
function handleGlobalFind(e) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return
|
||||
const k = e.key
|
||||
if (k === 'f' || k === 'F') {
|
||||
if (isEditableElement(e.target)) return
|
||||
e.preventDefault()
|
||||
if (!searchOpen.value) openSearch('')
|
||||
else {
|
||||
// If already open, select input text for quick overwrite
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// While open: Enter confirms current selection & closes dialog
|
||||
if (searchOpen.value && (k === 'Enter' || k === 'Return')) {
|
||||
e.preventDefault()
|
||||
activateCurrentResult()
|
||||
closeSearch()
|
||||
}
|
||||
function handleHeaderSearchActivate(result) {
|
||||
if (!result) return
|
||||
scrollToEventStart(result.startDate, true)
|
||||
// Open edit dialog for the event
|
||||
const ev = calendarStore.getEventById(result.id)
|
||||
if (ev) openEditEventDialog({ id: ev.id, event: ev })
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e) {
|
||||
if (!searchOpen.value) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeSearch()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
navigateSearch(1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
navigateSearch(-1)
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter inside input: activate current and close
|
||||
e.preventDefault()
|
||||
activateCurrentResult()
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleGlobalFind, { passive: false })
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleGlobalFind)
|
||||
})
|
||||
|
||||
// Ensure focus when (re)opening via reactive watch (catches programmatic toggles too)
|
||||
watch(
|
||||
() => searchOpen.value,
|
||||
(v) => {
|
||||
if (v) {
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||
// We explicitly avoid locale detection; rely solely on characters present.
|
||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||
@ -600,7 +490,12 @@ window.addEventListener('resize', () => {
|
||||
<div class="calendar-view-root" :dir="rtl && 'rtl'">
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<HeaderControls @go-to-today="goToToday" />
|
||||
<HeaderControls
|
||||
:reference-date="centerVisibleDateStr"
|
||||
@go-to-today="goToToday"
|
||||
@search-preview="handleHeaderSearchPreview"
|
||||
@search-activate="handleHeaderSearchActivate"
|
||||
/>
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
@ -648,33 +543,6 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
</div>
|
||||
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
||||
<!-- Event Search Overlay -->
|
||||
<div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown">
|
||||
<div class="search-row">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search events..."
|
||||
aria-label="Search events"
|
||||
autofocus
|
||||
/>
|
||||
<button type="button" @click="closeSearch" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
<ul class="results" v-if="searchResults.length">
|
||||
<li
|
||||
v-for="(r, i) in searchResults"
|
||||
:key="r.id"
|
||||
:class="{ active: i === searchIndex }"
|
||||
@click="((searchIndex = i), activateCurrentResult(), closeSearch())"
|
||||
>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="date">{{ r.startDate }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div>
|
||||
<div class="hint">Enter to go, Esc to close, ↑/↓ to browse</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -776,93 +644,4 @@ header h1 {
|
||||
height: var(--row-h);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Search overlay */
|
||||
.event-search {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
inset-inline-end: 0.75rem;
|
||||
z-index: 1200;
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
backdrop-filter: blur(0.75em);
|
||||
-webkit-backdrop-filter: blur(0.75em);
|
||||
color: var(--ink);
|
||||
padding: 0.75rem 0.75rem 0.6rem 0.75rem;
|
||||
border-radius: 0.6rem;
|
||||
width: min(28rem, 80vw);
|
||||
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.event-search .search-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.event-search input[type='text'] {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
.event-search button {
|
||||
background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
|
||||
color: var(--ink, #111);
|
||||
border: 0;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.event-search button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.event-search .results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.event-search .results li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.event-search .results li.active {
|
||||
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
||||
color: var(--ink, #111);
|
||||
font-weight: 600;
|
||||
}
|
||||
.event-search .results li:hover:not(.active) {
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
}
|
||||
.event-search .results .title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.event-search .results .date {
|
||||
opacity: 0.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
.event-search .no-results {
|
||||
padding: 0.25rem 0.1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.event-search .hint {
|
||||
opacity: 0.55;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div class="header-controls-wrapper">
|
||||
<Transition name="header-controls" appear>
|
||||
<div v-if="isVisible" class="header-controls">
|
||||
<EventSearch
|
||||
ref="eventSearchRef"
|
||||
:reference-date="referenceDate"
|
||||
@activate="handleSearchActivate"
|
||||
@preview="(r) => emit('search-preview', r)"
|
||||
/>
|
||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -43,12 +50,14 @@
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { formatTodayString } from '@/utils/date'
|
||||
import EventSearch from '@/components/Search.vue'
|
||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
@ -58,7 +67,8 @@ const todayString = computed(() => {
|
||||
return formatTodayString(d)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['go-to-today'])
|
||||
const emit = defineEmits(['go-to-today', 'search-activate', 'search-preview'])
|
||||
const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } })
|
||||
|
||||
function goToToday() {
|
||||
// Emit the event so the parent can handle the viewport scrolling logic
|
||||
@ -68,6 +78,8 @@ function goToToday() {
|
||||
|
||||
// Screen size detection and visibility toggle
|
||||
const isVisible = ref(false)
|
||||
// Track if we auto-opened due to a find (Ctrl/Cmd+F)
|
||||
const autoOpenedForSearch = ref(false)
|
||||
|
||||
function checkScreenSize() {
|
||||
const isSmallScreen = window.innerHeight < 600
|
||||
@ -77,6 +89,7 @@ function checkScreenSize() {
|
||||
|
||||
function toggleVisibility() {
|
||||
isVisible.value = !isVisible.value
|
||||
if (!isVisible.value) autoOpenedForSearch.value = false
|
||||
}
|
||||
|
||||
// Settings dialog integration
|
||||
@ -85,22 +98,75 @@ function openSettings() {
|
||||
settingsDialog.value?.open()
|
||||
}
|
||||
|
||||
// Search component ref exposure
|
||||
const eventSearchRef = ref(null)
|
||||
function focusSearch(selectAll = true) {
|
||||
eventSearchRef.value?.focusSearch(selectAll)
|
||||
}
|
||||
function isEditableElement(el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||
}
|
||||
defineExpose({ focusSearch })
|
||||
|
||||
function handleGlobalFind(e) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
if (isEditableElement(e.target)) return
|
||||
e.preventDefault()
|
||||
if (!isVisible.value) {
|
||||
isVisible.value = true
|
||||
autoOpenedForSearch.value = true
|
||||
} else {
|
||||
autoOpenedForSearch.value = false
|
||||
}
|
||||
// Defer focus until after transition renders input
|
||||
nextTick(() => requestAnimationFrame(() => focusSearch(true)))
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchActivate(r) {
|
||||
emit('search-activate', r)
|
||||
// Auto close only if we auto-opened for search shortcut
|
||||
if (autoOpenedForSearch.value) {
|
||||
isVisible.value = false
|
||||
}
|
||||
autoOpenedForSearch.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize()
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
document.addEventListener('keydown', handleGlobalFind, { passive: false })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
document.removeEventListener('keydown', handleGlobalFind)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-controls-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.5rem 0 0.5rem;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
margin-inline-end: 2rem;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding-inline-end: 2rem;
|
||||
}
|
||||
.header-controls :deep(.search-bar) {
|
||||
flex: 1 1 clamp(14rem, 40vw, 30rem);
|
||||
max-width: clamp(18rem, 40vw, 30rem);
|
||||
min-width: 12rem;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: fixed;
|
||||
@ -201,6 +267,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 1.5em;
|
||||
white-space: pre-line;
|
||||
text-align: center;
|
||||
margin-inline-end: 2rem;
|
||||
|
381
src/components/Search.vue
Normal file
381
src/components/Search.vue
Normal file
@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="search-bar" @keydown="onContainerKey">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Date or event..."
|
||||
aria-label="Search date and events"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
<ul
|
||||
v-if="searchQuery.trim() && searchResults.length"
|
||||
class="search-dropdown"
|
||||
role="listbox"
|
||||
:aria-activedescendant="activeResultId"
|
||||
>
|
||||
<li
|
||||
v-for="(r, i) in searchResults"
|
||||
:key="r.id"
|
||||
:id="'sr-' + r.id"
|
||||
:class="{ active: i === searchIndex }"
|
||||
role="option"
|
||||
@mousedown.prevent="selectResult(i)"
|
||||
>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="date">{{ r.startDate }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
|
||||
No matches
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed, defineExpose } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import {
|
||||
fromLocalString,
|
||||
DEFAULT_TZ,
|
||||
monthAbbr,
|
||||
getLocalizedMonthName,
|
||||
toLocalString,
|
||||
getMondayOfISOWeek,
|
||||
formatTodayString,
|
||||
makeTZDate,
|
||||
} from '@/utils/date'
|
||||
import { addDays } from 'date-fns'
|
||||
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
|
||||
import * as dateFns from 'date-fns'
|
||||
|
||||
const emit = defineEmits(['activate', 'preview'])
|
||||
const props = defineProps({ referenceDate: { type: String, default: null } })
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searchIndex = ref(0)
|
||||
const searchInputRef = ref(null)
|
||||
|
||||
function buildSearchResults() {
|
||||
const raw = searchQuery.value.trim()
|
||||
const q = raw.toLowerCase()
|
||||
if (!q) {
|
||||
searchResults.value = []
|
||||
searchIndex.value = 0
|
||||
return
|
||||
}
|
||||
const listAll = raw === '*'
|
||||
const out = []
|
||||
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local.
|
||||
let refStr = props.referenceDate || calendarStore.today || calendarStore.now
|
||||
// If it's full ISO (with time), slice date portion.
|
||||
if (refStr.includes('T')) refStr = refStr.slice(0, 10)
|
||||
const nowDate = fromLocalString(refStr, DEFAULT_TZ)
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
const title = (ev.title || '').trim()
|
||||
if (!title) continue
|
||||
if (!(listAll || title.toLowerCase().includes(q))) continue
|
||||
let displayStart = ev.startDate
|
||||
if (ev.recur) {
|
||||
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
|
||||
if (nearest && nearest.dateStr) displayStart = nearest.dateStr
|
||||
}
|
||||
out.push({ id: ev.id, title, startDate: displayStart })
|
||||
}
|
||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
||||
// Inject Go To Date option if query matches a date pattern (first item)
|
||||
const gotoDateStr = parseGoToDateCandidate(raw)
|
||||
if (gotoDateStr) {
|
||||
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
|
||||
const label = formatTodayString(dateObj).replace(/\n+/g, ' ')
|
||||
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true })
|
||||
}
|
||||
searchResults.value = out
|
||||
if (searchIndex.value >= out.length) searchIndex.value = 0
|
||||
}
|
||||
|
||||
watch(searchQuery, buildSearchResults)
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
if (searchQuery.value.trim()) buildSearchResults()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(
|
||||
() => props.referenceDate,
|
||||
() => {
|
||||
if (searchQuery.value.trim()) buildSearchResults()
|
||||
},
|
||||
)
|
||||
|
||||
function focusSearch(selectAll = true) {
|
||||
nextTick(() => {
|
||||
if (searchInputRef.value) {
|
||||
searchInputRef.value.focus()
|
||||
if (selectAll) searchInputRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function navigate(delta) {
|
||||
const n = searchResults.value.length
|
||||
if (!n) return
|
||||
searchIndex.value = (searchIndex.value + delta + n) % n
|
||||
const r = searchResults.value[searchIndex.value]
|
||||
if (r) emit('preview', r)
|
||||
}
|
||||
function selectResult(idx) {
|
||||
searchIndex.value = idx
|
||||
const r = searchResults.value[searchIndex.value]
|
||||
if (r) {
|
||||
emit('activate', r)
|
||||
// Clear query after activation (auto-close handled by parent visibility)
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
function handleSearchKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
navigate(1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
navigate(-1)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
selectResult(searchIndex.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (searchQuery.value) {
|
||||
searchQuery.value = ''
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
function onContainerKey(e) {
|
||||
/* capture for list navigation if needed */
|
||||
}
|
||||
|
||||
const activeResultId = computed(() => {
|
||||
const r = searchResults.value[searchIndex.value]
|
||||
return r ? 'sr-' + r.id : null
|
||||
})
|
||||
|
||||
defineExpose({ focusSearch })
|
||||
|
||||
function parseGoToDateCandidate(input) {
|
||||
const s = input.trim()
|
||||
if (!s) return null
|
||||
const today = new Date()
|
||||
const currentYear = today.getFullYear()
|
||||
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
|
||||
function monthFromToken(tok) {
|
||||
if (!tok) return null
|
||||
const t = tok.toLowerCase()
|
||||
if (/^\d{1,2}$/.test(t)) {
|
||||
const n = +t
|
||||
return n >= 1 && n <= 12 ? n : null
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ab = monthAbbr[i]
|
||||
if (t === ab || t === ab.slice(0, 3)) return i + 1
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const full = localized[i].toLowerCase()
|
||||
if (t === full || full.startsWith(t)) return i + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
// ISO full date or year-month (defaults day=1)
|
||||
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (mIsoFull) {
|
||||
const y = +mIsoFull[1],
|
||||
m = +mIsoFull[2],
|
||||
d = +mIsoFull[3]
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
}
|
||||
let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
|
||||
if (mIsoMonth) {
|
||||
const y = +mIsoMonth[1],
|
||||
m = +mIsoMonth[2]
|
||||
const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
}
|
||||
// ISO week
|
||||
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
|
||||
if (mWeek) {
|
||||
const wy = +mWeek[1],
|
||||
w = +mWeek[2]
|
||||
if (w >= 1 && w <= 53) {
|
||||
const jan4 = new Date(Date.UTC(wy, 0, 4))
|
||||
const target = addDays(jan4, (w - 1) * 7)
|
||||
const monday = getMondayOfISOWeek(target)
|
||||
return toLocalString(monday, DEFAULT_TZ)
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
|
||||
let d = null,
|
||||
m = null,
|
||||
y = null
|
||||
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/)
|
||||
if (mDot) {
|
||||
d = +mDot[1]
|
||||
m = monthFromToken(mDot[2])
|
||||
y = mDot[3] ? +mDot[3] : currentYear
|
||||
} else {
|
||||
// Slash month/day(/year) (month accepts names); year optional -> current year
|
||||
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||
if (mUSFull) {
|
||||
m = monthFromToken(mUSFull[1])
|
||||
d = +mUSFull[2]
|
||||
y = +mUSFull[3]
|
||||
} else {
|
||||
let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/)
|
||||
if (mUSShort) {
|
||||
m = monthFromToken(mUSShort[1])
|
||||
d = +mUSShort[2]
|
||||
y = currentYear
|
||||
}
|
||||
}
|
||||
}
|
||||
// Free-form with spaces: tokens containing month names and numbers
|
||||
if (!y && !m && !d) {
|
||||
const tokens = s.split(/[ ,]+/).filter(Boolean)
|
||||
if (tokens.length >= 2 && tokens.length <= 3) {
|
||||
// Prefer a token with letters as month over numeric month
|
||||
let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null)
|
||||
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
|
||||
if (monthIdx !== -1) {
|
||||
m = monthFromToken(tokens[monthIdx])
|
||||
const others = tokens.filter((_t, i) => i !== monthIdx)
|
||||
let dayExplicit = false
|
||||
for (const rawTok of others) {
|
||||
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
|
||||
if (!tok) continue
|
||||
if (/^\d+$/.test(tok)) {
|
||||
const num = +tok
|
||||
if (num > 100) {
|
||||
y = num
|
||||
} else if (!d) {
|
||||
d = num
|
||||
dayExplicit = true
|
||||
}
|
||||
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
|
||||
// salvage year with trailing punctuation
|
||||
const num = parseInt(tok, 10)
|
||||
if (num > 1000) y = num
|
||||
}
|
||||
}
|
||||
if (!y) y = currentYear
|
||||
// Only default day=1 if user didn't provide any day-ish numeric token
|
||||
if (!d && !dayExplicit) d = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (y != null && m != null && d != null) {
|
||||
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
}
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
position: relative;
|
||||
min-width: 14rem;
|
||||
flex: 1 1 clamp(14rem, 40vw, 30rem);
|
||||
max-width: clamp(18rem, 40vw, 30rem);
|
||||
min-width: 12rem;
|
||||
}
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 0.32rem 0.5rem;
|
||||
border-radius: 0.45rem;
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.1;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
background 0.2s;
|
||||
}
|
||||
.search-bar input:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
}
|
||||
.search-bar input::-webkit-search-cancel-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1400;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.2rem;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
backdrop-filter: blur(0.6em);
|
||||
-webkit-backdrop-filter: blur(0.6em);
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.search-dropdown li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.search-dropdown li.active {
|
||||
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
||||
color: var(--ink, #111);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search-dropdown li:hover:not(.active) {
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
}
|
||||
.search-dropdown .title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-dropdown .date {
|
||||
opacity: 0.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
.search-empty {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
backdrop-filter: blur(0.6em);
|
||||
-webkit-backdrop-filter: blur(0.6em);
|
||||
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
@ -285,10 +285,24 @@ export function createVirtualWeekManager({
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
|
||||
const todayDate = new Date(calendarStore.now)
|
||||
const targetWeekIndex = getWeekIndex(todayDate)
|
||||
scrollToWeekCentered(targetWeekIndex, 'go-to-today', true)
|
||||
}
|
||||
|
||||
function scrollToWeekCentered(weekIndex, reason = 'center-scroll', smooth = true) {
|
||||
if (weekIndex == null || !isFinite(weekIndex)) return
|
||||
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
|
||||
const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
// Center: subtract half viewport minus half row height
|
||||
let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2)
|
||||
newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll))
|
||||
if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') {
|
||||
viewport.value.scrollTo({ top: newScrollTop, behavior: 'smooth' })
|
||||
} else if (setScrollTopFn) {
|
||||
setScrollTopFn(newScrollTop, reason)
|
||||
scheduleWindowUpdate(reason)
|
||||
}
|
||||
}
|
||||
|
||||
function handleHeaderYearChange({ scrollTop }) {
|
||||
@ -308,6 +322,7 @@ export function createVirtualWeekManager({
|
||||
getWeekIndex,
|
||||
getFirstDayForVirtualWeek,
|
||||
goToToday,
|
||||
scrollToWeekCentered,
|
||||
handleHeaderYearChange,
|
||||
attachScroll,
|
||||
}
|
||||
|
@ -139,6 +139,68 @@ export function getDate(event, n, timeZone = DEFAULT_TZ) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Return nearest occurrence (past or future) relative to a reference date (date-string yyyy-MM-dd).
|
||||
* Falls back to first/last when reference lies before first or after last (bounded by cap).
|
||||
* Returns { n, dateStr } or null if no recurrence / invalid.
|
||||
*/
|
||||
export function getNearestOccurrence(event, referenceDateStr, timeZone = DEFAULT_TZ, cap = 5000) {
|
||||
if (!event) return null
|
||||
if (!event.recur) return { n: 0, dateStr: event.startDate }
|
||||
const { recur } = event
|
||||
if (!recur || !['weeks', 'months'].includes(recur.freq)) return { n: 0, dateStr: event.startDate }
|
||||
const refDate = fromLocalString(referenceDateStr, timeZone)
|
||||
const baseDate = fromLocalString(event.startDate, timeZone)
|
||||
if (refDate <= baseDate) return { n: 0, dateStr: event.startDate }
|
||||
const maxCount = recur.count === 'unlimited' ? cap : Math.min(parseInt(recur.count, 10) || 0, cap)
|
||||
if (maxCount <= 0) return null
|
||||
let low = 0
|
||||
let high = maxCount - 1
|
||||
let candidateGE = null
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1
|
||||
const midStr = getDate(event, mid, timeZone)
|
||||
if (!midStr) {
|
||||
// invalid mid (should rarely happen) shrink high
|
||||
high = mid - 1
|
||||
continue
|
||||
}
|
||||
const midDate = fromLocalString(midStr, timeZone)
|
||||
if (midDate >= refDate) {
|
||||
candidateGE = { n: mid, dateStr: midStr, date: midDate }
|
||||
high = mid - 1
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
let candidateLT = null
|
||||
if (candidateGE) {
|
||||
const prevN = candidateGE.n - 1
|
||||
if (prevN >= 0) {
|
||||
const prevStr = getDate(event, prevN, timeZone)
|
||||
if (prevStr) {
|
||||
candidateLT = { n: prevN, dateStr: prevStr, date: fromLocalString(prevStr, timeZone) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// All occurrences earlier than ref
|
||||
const lastN = maxCount - 1
|
||||
const lastStr = getDate(event, lastN, timeZone)
|
||||
if (lastStr)
|
||||
candidateLT = { n: lastN, dateStr: lastStr, date: fromLocalString(lastStr, timeZone) }
|
||||
}
|
||||
if (candidateGE && candidateLT) {
|
||||
const diffGE = candidateGE.date - refDate
|
||||
const diffLT = refDate - candidateLT.date
|
||||
return diffLT <= diffGE
|
||||
? { n: candidateLT.n, dateStr: candidateLT.dateStr }
|
||||
: { n: candidateGE.n, dateStr: candidateGE.dateStr }
|
||||
}
|
||||
if (candidateGE) return { n: candidateGE.n, dateStr: candidateGE.dateStr }
|
||||
if (candidateLT) return { n: candidateLT.n, dateStr: candidateLT.dateStr }
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
|
||||
const date = fromLocalString(dateStr, timeZone)
|
||||
const out = []
|
||||
|
Loading…
x
Reference in New Issue
Block a user