 abc7aba20f
			
		
	
	abc7aba20f
	
	
	
		
			
			- Now finds holidays in addition to dates and events - Emojis added to mark different result types - Matching improvements: insensitive to diacritics, finds closest holiday/date to view, concise regex matching - Avoid jumping to dates immediately while browsing the result dropdown - Improved hotkey handling Ctrl+F (always focus and select)
		
			
				
	
	
		
			575 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			575 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div class="search-bar" @keydown="onContainerKey">
 | |
|     <input
 | |
|       ref="searchInputRef"
 | |
|       v-model="searchQuery"
 | |
|       type="search"
 | |
|       placeholder="Date or Event..."
 | |
|       aria-label="Search dates, holidays 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, onUnmounted, onMounted } from 'vue'
 | |
| import { useCalendarStore } from '@/stores/CalendarStore'
 | |
| import {
 | |
|   fromLocalString,
 | |
|   DEFAULT_TZ,
 | |
|   monthAbbr,
 | |
|   getLocalizedMonthName,
 | |
|   toLocalString,
 | |
|   getMondayOfISOWeek,
 | |
|   formatTodayString,
 | |
|   makeTZDate,
 | |
|   getISOWeek,
 | |
| } from '@/utils/date'
 | |
| import { addDays } from 'date-fns'
 | |
| import { getDate as getNearestOccurrence } from '@/utils/events'
 | |
| import { getHolidaysForYear } from '@/utils/holidays'
 | |
| 
 | |
| 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)
 | |
| let previewTimer = null
 | |
| 
 | |
| // Accent-insensitive lowercasing
 | |
| const norm = (s) =>
 | |
|   s
 | |
|     .normalize('NFD')
 | |
|     .replace(/\p{Diacritic}/gu, '')
 | |
|     .toLowerCase()
 | |
| let lastQuery = ''
 | |
| let frozenRefStr = null // reference date frozen at last query change
 | |
| const YEAR_SCAN_OFFSETS = [-4, -3, -2, -1, 0, 1, 2, 3, 4]
 | |
| function buildSearchResults(queryChanged = false) {
 | |
|   const raw = searchQuery.value.trim()
 | |
|   if (!raw) {
 | |
|     searchResults.value = []
 | |
|     searchIndex.value = 0
 | |
|     lastQuery = raw
 | |
|     return
 | |
|   }
 | |
|   const listAll = raw === '*'
 | |
|   const search = norm(raw)
 | |
|   const out = []
 | |
|   let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now
 | |
|   if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10)
 | |
|   if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive
 | |
|   const refStr = frozenRefStr
 | |
|   const nowDate = fromLocalString(refStr, DEFAULT_TZ)
 | |
|   for (const ev of calendarStore.events.values()) {
 | |
|     const title = '⚜️ ' + (ev.title || '').trim()
 | |
|     if (!(listAll || norm(title).includes(search))) continue
 | |
|     let displayStart = ev.startDate
 | |
|     if (ev.recur) {
 | |
|       const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
 | |
|       if (nearest?.dateStr) displayStart = nearest.dateStr
 | |
|     }
 | |
|     out.push({ id: ev.id, title, startDate: displayStart })
 | |
|   }
 | |
|   if (calendarStore.config?.holidays?.enabled) {
 | |
|     try {
 | |
|       calendarStore._ensureHolidaysInitialized?.()
 | |
|       const refYear = nowDate.getFullYear()
 | |
|       const yearWindow = YEAR_SCAN_OFFSETS.map((o) => refYear + o)
 | |
|       const bestByName = Object.create(null)
 | |
|       for (const yr of yearWindow) {
 | |
|         for (const h of getHolidaysForYear(yr) || []) {
 | |
|           const name = (h.name || '').trim().split(/\s*\/\s*/)[0]
 | |
|           if (!name) continue
 | |
|           if (!listAll && !norm(name).includes(search)) continue
 | |
|           let dateObj
 | |
|           try {
 | |
|             dateObj = new Date(h.date)
 | |
|           } catch {
 | |
|             dateObj = null
 | |
|           }
 | |
|           if (!dateObj || isNaN(dateObj)) continue
 | |
|           const diff = Math.abs(dateObj - nowDate)
 | |
|           const key = name.toLowerCase()
 | |
|           const prev = bestByName[key]
 | |
|           if (!prev || diff < prev.diff) bestByName[key] = { name, dateObj, diff }
 | |
|         }
 | |
|       }
 | |
|       for (const key in bestByName) {
 | |
|         const { name, dateObj } = bestByName[key]
 | |
|         const dateStr = toLocalString(dateObj, DEFAULT_TZ)
 | |
|         out.push({
 | |
|           id: '__holiday__' + dateStr + ':' + key,
 | |
|           title: `✨ ${name}`,
 | |
|           startDate: dateStr,
 | |
|           _holiday: true,
 | |
|           _dupeKey: '__holiday__' + dateStr + ':' + key,
 | |
|         })
 | |
|       }
 | |
|     } catch (e) {
 | |
|       if (process.env.NODE_ENV !== 'production') console.debug('[Search] holiday search skipped', e)
 | |
|     }
 | |
|   }
 | |
|   if (queryChanged) {
 | |
|     out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
 | |
|   } else if (searchResults.value.length) {
 | |
|     const order = new Map(searchResults.value.map((r, i) => [r.id, i]))
 | |
|     out.sort((a, b) => {
 | |
|       const ai = order.has(a.id) ? order.get(a.id) : 1e9
 | |
|       const bi = order.has(b.id) ? order.get(b.id) : 1e9
 | |
|       if (ai !== bi) return ai - bi
 | |
|       return a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0
 | |
|     })
 | |
|   }
 | |
|   const gotoDateStr = parseGoToDateCandidate(raw, refStr)
 | |
|   if (gotoDateStr) {
 | |
|     const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
 | |
|     out.unshift({
 | |
|       id: '__goto__' + gotoDateStr,
 | |
|       title: '📅 ' + formatTodayString(dateObj),
 | |
|       startDate: gotoDateStr,
 | |
|       _goto: true,
 | |
|     })
 | |
|   }
 | |
|   searchResults.value = out
 | |
|   if (searchIndex.value >= out.length) searchIndex.value = 0
 | |
|   lastQuery = raw
 | |
| }
 | |
| 
 | |
| watch(searchQuery, (nv, ov) => {
 | |
|   buildSearchResults(nv.trim() !== lastQuery)
 | |
| })
 | |
| watch(
 | |
|   () => calendarStore.events,
 | |
|   () => {
 | |
|     if (searchQuery.value.trim()) buildSearchResults(false)
 | |
|   },
 | |
|   { deep: true },
 | |
| )
 | |
| watch(
 | |
|   () => props.referenceDate,
 | |
|   () => {
 | |
|     if (searchQuery.value.trim()) buildSearchResults(false)
 | |
|   },
 | |
| )
 | |
| 
 | |
| 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
 | |
|   // Ensure active item is visible
 | |
|   const r = searchResults.value[searchIndex.value]
 | |
|   if (r) {
 | |
|     const el = document.getElementById('sr-' + r.id)
 | |
|     if (el) el.scrollIntoView({ block: 'nearest' })
 | |
|   }
 | |
|   if (previewTimer) clearTimeout(previewTimer)
 | |
|   if (r)
 | |
|     previewTimer = setTimeout(() => {
 | |
|       if (r === searchResults.value[searchIndex.value]) emit('preview', r)
 | |
|     }, 200)
 | |
| }
 | |
| function selectResult(idx) {
 | |
|   searchIndex.value = idx
 | |
|   const r = searchResults.value[searchIndex.value]
 | |
|   if (r) {
 | |
|     if (previewTimer) {
 | |
|       clearTimeout(previewTimer)
 | |
|       previewTimer = null
 | |
|     }
 | |
|     emit('activate', r)
 | |
|     // Clear query after activation (auto-close handled by parent visibility)
 | |
|     searchQuery.value = ''
 | |
|   }
 | |
| }
 | |
| function handleSearchKeydown(e) {
 | |
|   if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
 | |
|     e.preventDefault()
 | |
|     e.stopPropagation()
 | |
|     focusSearch(true)
 | |
|     return
 | |
|   }
 | |
|   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 })
 | |
| onUnmounted(() => {
 | |
|   if (previewTimer) clearTimeout(previewTimer)
 | |
| })
 | |
| // global Ctrl/Cmd+F -> search
 | |
| let globalFindHandler = null
 | |
| onMounted(() => {
 | |
|   globalFindHandler = (e) => {
 | |
|     if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
 | |
|       e.preventDefault()
 | |
|       e.stopPropagation()
 | |
|       focusSearch(true)
 | |
|     }
 | |
|   }
 | |
|   window.addEventListener('keydown', globalFindHandler, { capture: true })
 | |
| })
 | |
| onUnmounted(() => {
 | |
|   if (globalFindHandler) {
 | |
|     window.removeEventListener('keydown', globalFindHandler, { capture: true })
 | |
|     globalFindHandler = null
 | |
|   }
 | |
| })
 | |
| 
 | |
| function parseGoToDateCandidate(input, refStr) {
 | |
|   const s = input.trim()
 | |
|   if (!s) return null
 | |
|   const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(),
 | |
|     baseYear = base.getFullYear()
 | |
|   // now/today -> system date
 | |
|   if (/^(now|today)$/i.test(s)) {
 | |
|     const sys = new Date()
 | |
|     return toLocalString(
 | |
|       makeTZDate(sys.getFullYear(), sys.getMonth(), sys.getDate(), DEFAULT_TZ),
 | |
|       DEFAULT_TZ,
 | |
|     )
 | |
|   }
 | |
|   const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
 | |
|   const monthFromToken = (tok) => {
 | |
|     if (!tok) return null
 | |
|     const tNorm = norm(tok.trim())
 | |
|     if (/^\d{1,2}$/.test(tok)) {
 | |
|       const n = +tok
 | |
|       return n >= 1 && n <= 12 ? n : null
 | |
|     }
 | |
|     for (let i = 0; i < 12; i++) {
 | |
|       if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
 | |
|     }
 | |
|     for (let i = 0; i < 12; i++) {
 | |
|       const full = norm(localized[i])
 | |
|       if (full === tNorm || full.startsWith(tNorm)) return i + 1
 | |
|     }
 | |
|     return null
 | |
|   }
 | |
|   // month token -> 15th of nearest year
 | |
|   const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u)
 | |
|   if (soleMonth) {
 | |
|     const rawMonthTok = soleMonth[1]
 | |
|     const m = monthFromToken(rawMonthTok)
 | |
|     if (m) {
 | |
|       let bestYear = baseYear
 | |
|       let best = Infinity
 | |
|       for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
 | |
|         const mid = new Date(cand, m - 1, 15)
 | |
|         const diff = Math.abs(mid - base)
 | |
|         if (diff < best) {
 | |
|           best = diff
 | |
|           bestYear = cand
 | |
|         }
 | |
|       }
 | |
|       return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
 | |
|     }
 | |
|   }
 | |
|   const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
 | |
|   if (isoFull) {
 | |
|     const y = +isoFull[1],
 | |
|       mm = +isoFull[2],
 | |
|       d = +isoFull[3]
 | |
|     return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ)
 | |
|   }
 | |
|   // wNN -> Monday of nearest ISO week
 | |
|   const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i)
 | |
|   if (weekOnly) {
 | |
|     const wk = +weekOnly[1]
 | |
|     if (wk >= 1 && wk <= 53) {
 | |
|       const has53Weeks = (year) => getISOWeek(makeTZDate(year, 11, 28, DEFAULT_TZ)) === 53
 | |
|       let bestYear = baseYear,
 | |
|         bestDiff = Infinity,
 | |
|         bestDate = null
 | |
|       for (const off of YEAR_SCAN_OFFSETS) {
 | |
|         const cand = baseYear + off
 | |
|         if (wk === 53 && !has53Weeks(cand)) continue
 | |
|         const jan4 = makeTZDate(cand, 0, 4, DEFAULT_TZ)
 | |
|         const target = addDays(jan4, (wk - 1) * 7)
 | |
|         const monday = getMondayOfISOWeek(target)
 | |
|         const diff = Math.abs(monday - base)
 | |
|         if (diff < bestDiff) {
 | |
|           bestDiff = diff
 | |
|           bestYear = cand
 | |
|           bestDate = monday
 | |
|         }
 | |
|       }
 | |
|       if (bestDate) return toLocalString(bestDate, DEFAULT_TZ)
 | |
|     }
 | |
|   }
 | |
|   const isoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
 | |
|   if (isoMonth) {
 | |
|     const y = +isoMonth[1],
 | |
|       mm = +isoMonth[2]
 | |
|     return toLocalString(makeTZDate(y, mm - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
 | |
|   }
 | |
|   // year+week variants
 | |
|   let isoWeek = s.match(/^(\d{4})[-/]?w(\d{1,2})$/i)
 | |
|   if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})[-/]?(\d{4})$/i)
 | |
|   if (!isoWeek) isoWeek = s.match(/^(\d{4})\s+w(\d{1,2})$/i)
 | |
|   if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})\s+(\d{4})$/i)
 | |
|   if (isoWeek) {
 | |
|     const wy = +isoWeek[1]
 | |
|     const w = +isoWeek[2]
 | |
|     if (w >= 1 && w <= 53) {
 | |
|       if (w === 53 && getISOWeek(makeTZDate(wy, 11, 28, DEFAULT_TZ)) !== 53) return null
 | |
|       const jan4 = makeTZDate(wy, 0, 4, DEFAULT_TZ)
 | |
|       const target = addDays(jan4, (w - 1) * 7)
 | |
|       return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ)
 | |
|     }
 | |
|     return null
 | |
|   }
 | |
|   let d = null,
 | |
|     m = null,
 | |
|     y = null,
 | |
|     yearExplicit = false
 | |
|   const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u)
 | |
|   if (dot) {
 | |
|     d = +dot[1]
 | |
|     m = monthFromToken(dot[2])
 | |
|     if (dot[3]) {
 | |
|       y = +dot[3]
 | |
|       yearExplicit = true
 | |
|     }
 | |
|   }
 | |
|   if (m == null) {
 | |
|     const usFull = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/u)
 | |
|     if (usFull) {
 | |
|       m = monthFromToken(usFull[1])
 | |
|       d = +usFull[2]
 | |
|       y = +usFull[3]
 | |
|       yearExplicit = true
 | |
|     } else {
 | |
|       const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
 | |
|       if (usShort) {
 | |
|         m = monthFromToken(usShort[1])
 | |
|         d = +usShort[2]
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   if (m == null) {
 | |
|     const tokens = s.split(/[ ,]+/).filter(Boolean)
 | |
|     if (tokens.length >= 2 && tokens.length <= 3) {
 | |
|       let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null)
 | |
|       if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
 | |
|       if (monthIdx !== -1) {
 | |
|         const monthTok = tokens[monthIdx]
 | |
|         const monthTokIsNum = /^\d{1,2}$/.test(monthTok)
 | |
|         const hasNonMonthLetter = tokens.some(
 | |
|           (t, i) => i !== monthIdx && /\p{L}/u.test(t) && monthFromToken(t) == null,
 | |
|         )
 | |
|         const otherNumeric = tokens.some((t, i) => i !== monthIdx && /^\d{1,2}$/.test(t))
 | |
|         if (monthTokIsNum && hasNonMonthLetter && !otherNumeric) {
 | |
|           monthIdx = -1
 | |
|         }
 | |
|       }
 | |
|       if (monthIdx !== -1) {
 | |
|         m = monthFromToken(tokens[monthIdx])
 | |
|         const others = tokens.filter((_, i) => i !== monthIdx)
 | |
|         let dayExplicit = false
 | |
|         for (const rawTok of others) {
 | |
|           const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '')
 | |
|           if (!tok) continue
 | |
|           if (/^\d+$/.test(tok)) {
 | |
|             const num = +tok
 | |
|             if (num > 100) {
 | |
|               y = num
 | |
|               yearExplicit = true
 | |
|             } else if (!d) {
 | |
|               d = num
 | |
|               dayExplicit = true
 | |
|             }
 | |
|           } else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
 | |
|             const num = parseInt(tok, 10)
 | |
|             if (num > 1000) {
 | |
|               y = num
 | |
|               yearExplicit = true
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|         if (!d && !dayExplicit) d = 15
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   if (m != null && d != null && !yearExplicit) {
 | |
|     let bestYear = baseYear,
 | |
|       bestDiff = Infinity
 | |
|     for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
 | |
|       const dt = new Date(cand, m - 1, d)
 | |
|       if (dt.getMonth() !== m - 1) continue
 | |
|       const diff = Math.abs(dt - base)
 | |
|       if (diff < bestDiff) {
 | |
|         bestDiff = diff
 | |
|         bestYear = cand
 | |
|       }
 | |
|     }
 | |
|     y = bestYear
 | |
|   }
 | |
|   if (y != null && m != null && d != null) {
 | |
|     if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
 | |
|     return toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
 | |
|   }
 | |
|   return null
 | |
| }
 | |
| </script>
 | |
| 
 | |
| <style scoped>
 | |
| .search-bar {
 | |
|   flex: 0 1 20rem;
 | |
|   margin-inline: auto; /* center with equal free-space on both sides */
 | |
|   position: relative;
 | |
| }
 | |
| .search-bar input {
 | |
|   width: 100%;
 | |
|   padding: 0.32rem 0.5rem;
 | |
|   padding-inline-start: 2.05rem; /* increased space for icon */
 | |
|   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;
 | |
|   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::before {
 | |
|   content: '🔍';
 | |
|   position: absolute;
 | |
|   inset-inline-start: 0.55rem;
 | |
|   top: 50%;
 | |
|   transform: translateY(-50%);
 | |
|   font-size: 0.85rem;
 | |
|   pointer-events: none;
 | |
|   opacity: 0.75;
 | |
|   line-height: 1;
 | |
|   filter: saturate(0.8);
 | |
| }
 | |
| .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>
 |