Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility #3

Merged
LeoVasanko merged 17 commits from vol003 into main 2025-08-27 13:41:46 +01:00
Showing only changes of commit b2bb6b2cde - Show all commits

View File

@ -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>