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)
This commit is contained in:
parent
57aefc5b4c
commit
abc7aba20f
@ -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;
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
if (calendarStore.config?.holidays?.enabled) {
|
||||||
// Inject Go To Date option if query matches a date pattern (first item)
|
try {
|
||||||
const gotoDateStr = parseGoToDateCandidate(raw)
|
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) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
|
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||||
if (mIsoMonth) {
|
if (isoFull) {
|
||||||
const y = +mIsoMonth[1],
|
const y = +isoFull[1],
|
||||||
m = +mIsoMonth[2]
|
mm = +isoFull[2],
|
||||||
const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ)
|
d = +isoFull[3]
|
||||||
return toLocalString(dt, DEFAULT_TZ)
|
return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
||||||
}
|
}
|
||||||
// ISO week
|
// wNN -> Monday of nearest ISO week
|
||||||
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
|
const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i)
|
||||||
if (mWeek) {
|
if (weekOnly) {
|
||||||
const wy = +mWeek[1],
|
const wk = +weekOnly[1]
|
||||||
w = +mWeek[2]
|
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 >= 1 && w <= 53) {
|
||||||
const jan4 = new Date(Date.UTC(wy, 0, 4))
|
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)
|
const target = addDays(jan4, (w - 1) * 7)
|
||||||
const monday = getMondayOfISOWeek(target)
|
return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ)
|
||||||
return toLocalString(monday, 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])
|
||||||
} else {
|
if (dot[3]) {
|
||||||
// Slash month/day(/year) (month accepts names); year optional -> current year
|
y = +dot[3]
|
||||||
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
yearExplicit = true
|
||||||
if (mUSFull) {
|
}
|
||||||
m = monthFromToken(mUSFull[1])
|
}
|
||||||
d = +mUSFull[2]
|
if (m == null) {
|
||||||
y = +mUSFull[3]
|
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 {
|
||||||
let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/)
|
const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
|
||||||
if (mUSShort) {
|
if (usShort) {
|
||||||
m = monthFromToken(mUSShort[1])
|
m = monthFromToken(usShort[1])
|
||||||
d = +mUSShort[2]
|
d = +usShort[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
|
if (!d && !dayExplicit) d = 15
|
||||||
// Only default day=1 if user didn't provide any day-ish numeric token
|
|
||||||
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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user