Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dee8ce5079 | ||
![]() |
abc7aba20f |
@ -599,15 +599,15 @@ const recurrenceSummary = computed(() => {
|
||||
<div class="line compact">
|
||||
<Numeric
|
||||
v-model="displayInterval"
|
||||
:prefix-values="[{ value: 1, display: 'Every' }]"
|
||||
:prefix-values="[{ value: 1, display: 'All' }]"
|
||||
:min="2"
|
||||
number-prefix="Every "
|
||||
aria-label="Interval"
|
||||
/>
|
||||
<select v-model="displayFrequency" class="freq-select">
|
||||
<option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option>
|
||||
<option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option>
|
||||
<option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option>
|
||||
<option value="weeks">{{ 'weeks' }}</option>
|
||||
<option value="months">{{ 'months' }}</option>
|
||||
<option value="years">{{ 'years' }}</option>
|
||||
</select>
|
||||
<Numeric
|
||||
class="occ-stepper"
|
||||
|
@ -166,12 +166,6 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -4,8 +4,8 @@
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Date or event..."
|
||||
aria-label="Search date and events"
|
||||
placeholder="Date or Event..."
|
||||
aria-label="Search dates, holidays and events"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
<ul
|
||||
@ -22,8 +22,8 @@
|
||||
role="option"
|
||||
@mousedown.prevent="selectResult(i)"
|
||||
>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="date">{{ r.startDate }}</span>
|
||||
<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">
|
||||
@ -33,7 +33,7 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
fromLocalString,
|
||||
@ -44,10 +44,11 @@ import {
|
||||
getMondayOfISOWeek,
|
||||
formatTodayString,
|
||||
makeTZDate,
|
||||
getISOWeek,
|
||||
} from '@/utils/date'
|
||||
import { addDays } from 'date-fns'
|
||||
import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events'
|
||||
import * as dateFns 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 } })
|
||||
@ -57,57 +58,122 @@ const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searchIndex = ref(0)
|
||||
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 q = raw.toLowerCase()
|
||||
if (!q) {
|
||||
if (!raw) {
|
||||
searchResults.value = []
|
||||
searchIndex.value = 0
|
||||
lastQuery = raw
|
||||
return
|
||||
}
|
||||
const listAll = raw === '*'
|
||||
const search = norm(raw)
|
||||
const out = []
|
||||
// Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local.
|
||||
let refStr = props.referenceDate || calendarStore.today || calendarStore.now
|
||||
// If it's full ISO (with time), slice date portion.
|
||||
if (refStr.includes('T')) refStr = refStr.slice(0, 10)
|
||||
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 (!title) continue
|
||||
if (!(listAll || title.toLowerCase().includes(q))) continue
|
||||
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 && nearest.dateStr) displayStart = nearest.dateStr
|
||||
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))
|
||||
// Inject Go To Date option if query matches a date pattern (first item)
|
||||
const gotoDateStr = parseGoToDateCandidate(raw)
|
||||
} 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)
|
||||
const label = formatTodayString(dateObj).replace(/\n+/g, ' ')
|
||||
out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true })
|
||||
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, buildSearchResults)
|
||||
watch(searchQuery, (nv, ov) => {
|
||||
buildSearchResults(nv.trim() !== lastQuery)
|
||||
})
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
if (searchQuery.value.trim()) buildSearchResults()
|
||||
if (searchQuery.value.trim()) buildSearchResults(false)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(
|
||||
() => 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
|
||||
if (!n) return
|
||||
searchIndex.value = (searchIndex.value + delta + n) % n
|
||||
// Ensure active item is visible
|
||||
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) {
|
||||
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)
|
||||
@ -163,122 +248,220 @@ const activeResultId = computed(() => {
|
||||
})
|
||||
|
||||
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()
|
||||
if (!s) return null
|
||||
const today = new Date()
|
||||
const currentYear = today.getFullYear()
|
||||
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))
|
||||
function monthFromToken(tok) {
|
||||
const monthFromToken = (tok) => {
|
||||
if (!tok) return null
|
||||
const t = tok.toLowerCase()
|
||||
if (/^\d{1,2}$/.test(t)) {
|
||||
const n = +t
|
||||
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++) {
|
||||
const ab = monthAbbr[i]
|
||||
if (t === ab || t === ab.slice(0, 3)) return i + 1
|
||||
if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const full = localized[i].toLowerCase()
|
||||
if (t === full || full.startsWith(t)) return i + 1
|
||||
const full = norm(localized[i])
|
||||
if (full === tNorm || full.startsWith(tNorm)) return i + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
// ISO full date or year-month (defaults day=1)
|
||||
let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (mIsoFull) {
|
||||
const y = +mIsoFull[1],
|
||||
m = +mIsoFull[2],
|
||||
d = +mIsoFull[3]
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
// 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
|
||||
}
|
||||
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
|
||||
const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i)
|
||||
if (mWeek) {
|
||||
const wy = +mWeek[1],
|
||||
w = +mWeek[2]
|
||||
if (w >= 1 && w <= 53) {
|
||||
const jan4 = new Date(Date.UTC(wy, 0, 4))
|
||||
const target = addDays(jan4, (w - 1) * 7)
|
||||
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)
|
||||
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
|
||||
}
|
||||
// Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year.
|
||||
let d = null,
|
||||
m = null,
|
||||
y = null
|
||||
let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/)
|
||||
if (mDot) {
|
||||
d = +mDot[1]
|
||||
m = monthFromToken(mDot[2])
|
||||
y = mDot[3] ? +mDot[3] : currentYear
|
||||
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 {
|
||||
// Slash month/day(/year) (month accepts names); year optional -> current year
|
||||
let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||
if (mUSFull) {
|
||||
m = monthFromToken(mUSFull[1])
|
||||
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
|
||||
const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
|
||||
if (usShort) {
|
||||
m = monthFromToken(usShort[1])
|
||||
d = +usShort[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Free-form with spaces: tokens containing month names and numbers
|
||||
if (!y && !m && !d) {
|
||||
if (m == null) {
|
||||
const tokens = s.split(/[ ,]+/).filter(Boolean)
|
||||
if (tokens.length >= 2 && tokens.length <= 3) {
|
||||
// Prefer a token with letters as month over numeric month
|
||||
let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null)
|
||||
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((_t, i) => i !== monthIdx)
|
||||
const others = tokens.filter((_, i) => i !== monthIdx)
|
||||
let dayExplicit = false
|
||||
for (const rawTok of others) {
|
||||
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation
|
||||
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)) {
|
||||
// salvage year with trailing punctuation
|
||||
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 = 1
|
||||
}
|
||||
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
|
||||
const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ)
|
||||
return toLocalString(dt, DEFAULT_TZ)
|
||||
return toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -286,20 +469,18 @@ function parseGoToDateCandidate(input) {
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
flex: 0 1 20rem;
|
||||
margin-inline: auto; /* center with equal free-space on both sides */
|
||||
position: relative;
|
||||
min-width: 14rem;
|
||||
flex: 1 1 clamp(14rem, 40vw, 30rem);
|
||||
max-width: clamp(18rem, 40vw, 30rem);
|
||||
min-width: 12rem;
|
||||
}
|
||||
.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;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.1;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
@ -308,6 +489,18 @@ function parseGoToDateCandidate(input) {
|
||||
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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user