Compare commits

...

2 Commits
v0.1.0 ... main

Author SHA1 Message Date
Leo Vasanko
dee8ce5079 Event dialog shows All instead of Every (1). 2025-08-28 00:35:31 -06:00
Leo Vasanko
abc7aba20f Search improvements.
- 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)
2025-08-27 23:39:33 -06:00
3 changed files with 294 additions and 107 deletions

View File

@ -599,15 +599,15 @@ const recurrenceSummary = computed(() => {
<div class="line compact"> <div class="line compact">
<Numeric <Numeric
v-model="displayInterval" v-model="displayInterval"
:prefix-values="[{ value: 1, display: 'Every' }]" :prefix-values="[{ value: 1, display: 'All' }]"
:min="2" :min="2"
number-prefix="Every " number-prefix="Every "
aria-label="Interval" aria-label="Interval"
/> />
<select v-model="displayFrequency" class="freq-select"> <select v-model="displayFrequency" class="freq-select">
<option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option> <option value="weeks">{{ 'weeks' }}</option>
<option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option> <option value="months">{{ 'months' }}</option>
<option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option> <option value="years">{{ 'years' }}</option>
</select> </select>
<Numeric <Numeric
class="occ-stepper" class="occ-stepper"

View File

@ -166,12 +166,6 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
padding-inline-end: 2rem; 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 { .toggle-btn {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -4,8 +4,8 @@
ref="searchInputRef" ref="searchInputRef"
v-model="searchQuery" v-model="searchQuery"
type="search" type="search"
placeholder="Date or event..." placeholder="Date or Event..."
aria-label="Search date and events" aria-label="Search dates, holidays and events"
@keydown="handleSearchKeydown" @keydown="handleSearchKeydown"
/> />
<ul <ul
@ -22,8 +22,8 @@
role="option" role="option"
@mousedown.prevent="selectResult(i)" @mousedown.prevent="selectResult(i)"
> >
<span class="title">{{ r.title }}</span> <span class="title">{{ r.title }}</span
<span class="date">{{ r.startDate }}</span> ><span class="date">{{ r.startDate }}</span>
</li> </li>
</ul> </ul>
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty"> <div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
@ -33,7 +33,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, nextTick, computed, defineExpose } from 'vue' import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { import {
fromLocalString, fromLocalString,
@ -44,10 +44,11 @@ import {
getMondayOfISOWeek, getMondayOfISOWeek,
formatTodayString, formatTodayString,
makeTZDate, makeTZDate,
getISOWeek,
} from '@/utils/date' } from '@/utils/date'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events' import { getDate as getNearestOccurrence } from '@/utils/events'
import * as dateFns from 'date-fns' import { getHolidaysForYear } from '@/utils/holidays'
const emit = defineEmits(['activate', 'preview']) const emit = defineEmits(['activate', 'preview'])
const props = defineProps({ referenceDate: { type: String, default: null } }) const props = defineProps({ referenceDate: { type: String, default: null } })
@ -57,57 +58,122 @@ const searchQuery = ref('')
const searchResults = ref([]) const searchResults = ref([])
const searchIndex = ref(0) const searchIndex = ref(0)
const searchInputRef = ref(null) const searchInputRef = ref(null)
let previewTimer = null
function buildSearchResults() { // 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() const raw = searchQuery.value.trim()
const q = raw.toLowerCase() if (!raw) {
if (!q) {
searchResults.value = [] searchResults.value = []
searchIndex.value = 0 searchIndex.value = 0
lastQuery = raw
return return
} }
const listAll = raw === '*' const listAll = raw === '*'
const search = norm(raw)
const out = [] const out = []
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local. let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now
let refStr = props.referenceDate || calendarStore.today || calendarStore.now if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10)
// If it's full ISO (with time), slice date portion. if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive
if (refStr.includes('T')) refStr = refStr.slice(0, 10) const refStr = frozenRefStr
const nowDate = fromLocalString(refStr, DEFAULT_TZ) const nowDate = fromLocalString(refStr, DEFAULT_TZ)
for (const ev of calendarStore.events.values()) { for (const ev of calendarStore.events.values()) {
const title = (ev.title || '').trim() const title = '⚜️ ' + (ev.title || '').trim()
if (!title) continue if (!(listAll || norm(title).includes(search))) continue
if (!(listAll || title.toLowerCase().includes(q))) continue
let displayStart = ev.startDate let displayStart = ev.startDate
if (ev.recur) { if (ev.recur) {
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ) const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
if (nearest && nearest.dateStr) displayStart = nearest.dateStr if (nearest?.dateStr) displayStart = nearest.dateStr
} }
out.push({ id: ev.id, title, startDate: displayStart }) 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)) 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) } else if (searchResults.value.length) {
const gotoDateStr = parseGoToDateCandidate(raw) 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) { if (gotoDateStr) {
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ) const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
const label = formatTodayString(dateObj).replace(/\n+/g, ' ') out.unshift({
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true }) id: '__goto__' + gotoDateStr,
title: '📅 ' + formatTodayString(dateObj),
startDate: gotoDateStr,
_goto: true,
})
} }
searchResults.value = out searchResults.value = out
if (searchIndex.value >= out.length) searchIndex.value = 0 if (searchIndex.value >= out.length) searchIndex.value = 0
lastQuery = raw
} }
watch(searchQuery, buildSearchResults) watch(searchQuery, (nv, ov) => {
buildSearchResults(nv.trim() !== lastQuery)
})
watch( watch(
() => calendarStore.events, () => calendarStore.events,
() => { () => {
if (searchQuery.value.trim()) buildSearchResults() if (searchQuery.value.trim()) buildSearchResults(false)
}, },
{ deep: true }, { deep: true },
) )
watch( watch(
() => props.referenceDate, () => props.referenceDate,
() => { () => {
if (searchQuery.value.trim()) buildSearchResults() if (searchQuery.value.trim()) buildSearchResults(false)
}, },
) )
@ -124,19 +190,38 @@ function navigate(delta) {
const n = searchResults.value.length const n = searchResults.value.length
if (!n) return if (!n) return
searchIndex.value = (searchIndex.value + delta + n) % n searchIndex.value = (searchIndex.value + delta + n) % n
// Ensure active item is visible
const r = searchResults.value[searchIndex.value] const r = searchResults.value[searchIndex.value]
if (r) emit('preview', r) 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) { function selectResult(idx) {
searchIndex.value = idx searchIndex.value = idx
const r = searchResults.value[searchIndex.value] const r = searchResults.value[searchIndex.value]
if (r) { if (r) {
if (previewTimer) {
clearTimeout(previewTimer)
previewTimer = null
}
emit('activate', r) emit('activate', r)
// Clear query after activation (auto-close handled by parent visibility) // Clear query after activation (auto-close handled by parent visibility)
searchQuery.value = '' searchQuery.value = ''
} }
} }
function handleSearchKeydown(e) { 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') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
navigate(1) navigate(1)
@ -163,122 +248,220 @@ const activeResultId = computed(() => {
}) })
defineExpose({ focusSearch }) 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) { function parseGoToDateCandidate(input, refStr) {
const s = input.trim() const s = input.trim()
if (!s) return null if (!s) return null
const today = new Date() const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(),
const currentYear = today.getFullYear() 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 localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
function monthFromToken(tok) { const monthFromToken = (tok) => {
if (!tok) return null if (!tok) return null
const t = tok.toLowerCase() const tNorm = norm(tok.trim())
if (/^\d{1,2}$/.test(t)) { if (/^\d{1,2}$/.test(tok)) {
const n = +t const n = +tok
return n >= 1 && n <= 12 ? n : null return n >= 1 && n <= 12 ? n : null
} }
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const ab = monthAbbr[i] if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
if (t === ab || t === ab.slice(0, 3)) return i + 1
} }
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const full = localized[i].toLowerCase() const full = norm(localized[i])
if (t === full || full.startsWith(t)) return i + 1 if (full === tNorm || full.startsWith(tNorm)) return i + 1
} }
return null return null
} }
// ISO full date or year-month (defaults day=1) // month token -> 15th of nearest year
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/) const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u)
if (mIsoFull) { if (soleMonth) {
const y = +mIsoFull[1], const rawMonthTok = soleMonth[1]
m = +mIsoFull[2], const m = monthFromToken(rawMonthTok)
d = +mIsoFull[3] if (m) {
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ) let bestYear = baseYear
return toLocalString(dt, DEFAULT_TZ) 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
} }
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 return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i) }
if (mWeek) { }
const wy = +mWeek[1], const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
w = +mWeek[2] if (isoFull) {
if (w >= 1 && w <= 53) { const y = +isoFull[1],
const jan4 = new Date(Date.UTC(wy, 0, 4)) mm = +isoFull[2],
const target = addDays(jan4, (w - 1) * 7) 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 monday = getMondayOfISOWeek(target)
return toLocalString(monday, DEFAULT_TZ) 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 return null
} }
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
let d = null, let d = null,
m = null, m = null,
y = null y = null,
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/) yearExplicit = false
if (mDot) { const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u)
d = +mDot[1] if (dot) {
m = monthFromToken(mDot[2]) d = +dot[1]
y = mDot[3] ? +mDot[3] : currentYear 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 { } else {
// Slash month/day(/year) (month accepts names); year optional -> current year const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/) if (usShort) {
if (mUSFull) { m = monthFromToken(usShort[1])
m = monthFromToken(mUSFull[1]) d = +usShort[2]
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 (m == null) {
if (!y && !m && !d) {
const tokens = s.split(/[ ,]+/).filter(Boolean) const tokens = s.split(/[ ,]+/).filter(Boolean)
if (tokens.length >= 2 && tokens.length <= 3) { if (tokens.length >= 2 && tokens.length <= 3) {
// Prefer a token with letters as month over numeric month let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null)
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) 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) { if (monthIdx !== -1) {
m = monthFromToken(tokens[monthIdx]) m = monthFromToken(tokens[monthIdx])
const others = tokens.filter((_t, i) => i !== monthIdx) const others = tokens.filter((_, i) => i !== monthIdx)
let dayExplicit = false let dayExplicit = false
for (const rawTok of others) { for (const rawTok of others) {
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '')
if (!tok) continue if (!tok) continue
if (/^\d+$/.test(tok)) { if (/^\d+$/.test(tok)) {
const num = +tok const num = +tok
if (num > 100) { if (num > 100) {
y = num y = num
yearExplicit = true
} else if (!d) { } else if (!d) {
d = num d = num
dayExplicit = true dayExplicit = true
} }
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) { } else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
// salvage year with trailing punctuation
const num = parseInt(tok, 10) const num = parseInt(tok, 10)
if (num > 1000) y = num if (num > 1000) {
y = num
yearExplicit = true
} }
} }
if (!y) y = currentYear }
// Only default day=1 if user didn't provide any day-ish numeric token if (!d && !dayExplicit) d = 15
if (!d && !dayExplicit) d = 1
} }
} }
} }
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 != null && m != null && d != null) {
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return 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(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
return toLocalString(dt, DEFAULT_TZ)
} }
return null return null
} }
@ -286,20 +469,18 @@ function parseGoToDateCandidate(input) {
<style scoped> <style scoped>
.search-bar { .search-bar {
flex: 0 1 20rem;
margin-inline: auto; /* center with equal free-space on both sides */
position: relative; position: relative;
min-width: 14rem;
flex: 1 1 clamp(14rem, 40vw, 30rem);
max-width: clamp(18rem, 40vw, 30rem);
min-width: 12rem;
} }
.search-bar input { .search-bar input {
width: 100%; width: 100%;
padding: 0.32rem 0.5rem; padding: 0.32rem 0.5rem;
padding-inline-start: 2.05rem; /* increased space for icon */
border-radius: 0.45rem; border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
background: color-mix(in srgb, var(--panel) 88%, transparent); background: color-mix(in srgb, var(--panel) 88%, transparent);
font: inherit; font: inherit;
font-size: 0.8rem;
line-height: 1.1; line-height: 1.1;
color: var(--ink); color: var(--ink);
outline: none; outline: none;
@ -308,6 +489,18 @@ function parseGoToDateCandidate(input) {
box-shadow 0.15s ease, box-shadow 0.15s ease,
background 0.2s; 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 { .search-bar input:focus-visible {
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent); 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); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);