import type { DateValue } from '@internationalized/date' import type { WeekStartsOn } from '@/date' import { toCalendar, toCalendarDate } from '@internationalized/date' import { nextTick } from 'vue' import { getDaysInMonth, getLastFirstDayOfWeek, getNextLastDayOfWeek } from '@/date' /** * Upper bound on focus-search steps when skipping over disabled/unavailable * days or waiting for an async grid re-render. Covers a generous run of * consecutive blocked days (more than three weeks) while guaranteeing the * recursion terminates if no focusable day can be found. */ export const MAX_FOCUS_RETRIES = 24 interface FocusDateOptions { parentElement: HTMLElement target: DateValue directionSign: number minValue?: DateValue maxValue?: DateValue onPlaceholderChange: (date: DateValue) => void allowOutsideView?: boolean } interface FocusWeekBoundaryOptions extends Omit { baseDate: DateValue boundary: 'start' | 'end' locale: string weekStartsOn: WeekStartsOn } interface FocusPaginationOptions extends Omit { baseDate: DateValue isNext: boolean isYear: boolean isOutsideVisibleView: (date: DateValue) => boolean isNextButtonDisabled: (customPageFn?: (date: DateValue) => DateValue) => boolean isPrevButtonDisabled: (customPageFn?: (date: DateValue) => DateValue) => boolean nextPage: (customPageFn?: (date: DateValue) => DateValue) => void prevPage: (customPageFn?: (date: DateValue) => DateValue) => void } export function focusWeekBoundary(options: FocusWeekBoundaryOptions) { const { baseDate, boundary, locale, weekStartsOn } = options const target = boundary === 'start' ? getLastFirstDayOfWeek(baseDate, weekStartsOn, locale) : getNextLastDayOfWeek(baseDate, weekStartsOn, locale) // The sign points outward toward the boundary; clamp/fallback logic in // `focusDate` turns it inward when the boundary day itself is not focusable. const directionSign = boundary === 'start' ? -1 : 1 focusDate({ ...options, target, directionSign, allowOutsideView: true }) } export function focusPagination(options: FocusPaginationOptions) { const { baseDate, isNext, isYear, isOutsideVisibleView } = options const amount = isNext ? 1 : -1 const target = clampTargetDate( getTargetMonthDay(baseDate, isYear ? 'year' : 'month', amount), options.minValue, options.maxValue, ) const customPageFn = (date: DateValue) => { const pageDate = isYear ? date.add({ years: amount }) : date.add({ months: amount }) return pageDate.set({ day: 1 }) } if (isOutsideVisibleView(target)) { if (isNext && !options.isNextButtonDisabled(customPageFn)) options.nextPage(customPageFn) else if (!isNext && !options.isPrevButtonDisabled(customPageFn)) options.prevPage(customPageFn) } focusDate({ ...options, target, directionSign: amount }) } export function getTargetMonthDay(date: DateValue, unit: 'month' | 'year', amount: number): DateValue { const targetBase = date.add(unit === 'month' ? { months: amount } : { years: amount }) const daysInTargetMonth = getDaysInMonth(targetBase) const targetDay = Math.min(date.day, daysInTargetMonth) return targetBase.set({ day: targetDay }) } /** Compares the calendar days of two dates, ignoring their time and type. */ function compareDay(date: DateValue, reference: DateValue) { return toCalendarDate(date).compare(toCalendarDate(reference)) } /** * Moves `date` to the day of `bound`. The result keeps the type, time and * calendar of `date`, because the grid cells it has to match are built from * the placeholder and not from `minValue` / `maxValue`. */ function setDay(date: DateValue, bound: DateValue): DateValue { const day = toCalendar(toCalendarDate(bound), date.calendar) return date.set({ era: day.era, year: day.year, month: day.month, day: day.day }) } export function clampTargetDate(date: DateValue, minValue?: DateValue, maxValue?: DateValue): DateValue { let candidateDay = date if (minValue && compareDay(candidateDay, minValue) < 0) candidateDay = setDay(candidateDay, minValue) if (maxValue && compareDay(candidateDay, maxValue) > 0) candidateDay = setDay(candidateDay, maxValue) return candidateDay } function focusDate(options: FocusDateOptions) { const clampedTarget = clampTargetDate(options.target, options.minValue, options.maxValue) // Determine the direction to search when the target day is not focusable: // - clamped to minValue from negative movement -> +1 (inward from the lower bound) // - clamped to maxValue from positive movement -> -1 (inward from the upper bound) // - unclamped week boundary -> search back toward the base day so focus stays // within the current week instead of spilling into the adjacent week // - unclamped pagination -> keep travelling in the navigation direction // - otherwise -> 0 (no search) let safeDirection = 0 const isClampedToMin = options.minValue && compareDay(clampedTarget, options.minValue) === 0 const isClampedToMax = options.maxValue && compareDay(clampedTarget, options.maxValue) === 0 const wasClamped = isClampedToMin || isClampedToMax if (!wasClamped) { safeDirection = options.allowOutsideView ? -options.directionSign : options.directionSign } else if (isClampedToMin && options.directionSign < 0) { safeDirection = 1 } else if (isClampedToMax && options.directionSign > 0) { safeDirection = -1 } options.onPlaceholderChange(clampedTarget) nextTick(() => tryFocusDate(options.parentElement, clampedTarget, safeDirection, 0, options)) } function tryFocusDate( parentElement: HTMLElement, target: DateValue, directionSign: number, retries: number, options: FocusDateOptions, ) { if (retries >= MAX_FOCUS_RETRIES) return // With multiple months, a date can render both in its own grid and as an // outside-view copy in a neighbouring grid; always prefer the in-month cell. const valueSelector = `[data-value='${target.toString()}']` const candidateDay = parentElement.querySelector(`${valueSelector}:not([data-outside-view])`) ?? (options.allowOutsideView ? parentElement.querySelector(valueSelector) : null) if (!candidateDay) { nextTick(() => tryFocusDate(parentElement, target, directionSign, retries + 1, options)) return } if (candidateDay.hasAttribute('data-disabled') || candidateDay.hasAttribute('data-unavailable')) { if (!directionSign) return const nextTarget = clampTargetDate(target.add({ days: directionSign }), options.minValue, options.maxValue) if (nextTarget.compare(target) === 0) return options.onPlaceholderChange(nextTarget) nextTick(() => tryFocusDate(parentElement, nextTarget, directionSign, retries + 1, options)) return } if (options.allowOutsideView && candidateDay.hasAttribute('data-outside-view') && !candidateDay.hasAttribute('tabindex')) candidateDay.setAttribute('tabindex', '-1') candidateDay.focus() }