Simple find
This commit is contained in:
parent
b539c71611
commit
b2bb6b2cde
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
@ -397,6 +397,154 @@ 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.eventsMutation,
|
||||
() => {
|
||||
if (searchOpen.value && searchQuery.value.trim()) buildSearchResults()
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
try {
|
||||
const dateObj = fromLocalString(cur.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')
|
||||
} catch {}
|
||||
}
|
||||
function activateCurrentResult() {
|
||||
scrollToCurrentResult()
|
||||
}
|
||||
|
||||
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 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.
|
||||
@ -496,6 +644,37 @@ 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>
|
||||
@ -597,4 +776,93 @@ 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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user