Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility #3
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<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 { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||||
@ -397,6 +397,154 @@ const handleEventClick = (payload) => {
|
|||||||
openEditEventDialog(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.
|
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||||
// We explicitly avoid locale detection; rely solely on characters present.
|
// We explicitly avoid locale detection; rely solely on characters present.
|
||||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||||
@ -496,6 +644,37 @@ window.addEventListener('resize', () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -597,4 +776,93 @@ header h1 {
|
|||||||
height: var(--row-h);
|
height: var(--row-h);
|
||||||
pointer-events: none;
|
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>
|
</style>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user