'use client'; import * as React from 'react'; import { addDays, addMonths, addYears, isSameMonth, isSameYear, startOfMonth, startOfYear, type DateGranularity, } from './date-utils'; /** * The calendar's roving focus and its arrow keys. * * Which cell the keyboard is on is inseparable from what the keys do to it, so * the state, the focus-chasing effect and the handler travel together — three * things the panel would otherwise interleave with its header, its three grid * layouts and its cell styling. * * Deliberately **not** exported from `index.ts`: `pnpm exports:gen` gives every * `index.ts` a subpath, and this is the panel's private navigation. */ /** * A cell is identified by its date, so focus survives the grid re-rendering * around it when a keypress crosses into the next month. * * One home for the format, used by the effect below and by every cell the panel * draws — they were two copies of the same template literal, and a divergence * would have shown up as focus silently failing to follow the arrows. */ export const cellIdFor = (gridId: string, date: Date) => `${gridId}-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; export function useCalendarKeyboard({ picker, value, month, onMonthChange, weekStart, disabledDate, onSelect, gridRef, gridId, }: { picker: DateGranularity; /** Seeds the focused cell on the first render only. */ value: Date | null; month: Date; onMonthChange: (month: Date) => void; /** First day of the week for this locale — what Home and End are relative to. */ weekStart: number; disabledDate?: (date: Date) => boolean; onSelect: (date: Date) => void; gridRef: React.RefObject; gridId: string; }) { /** * The cell the keyboard is on. Only one cell in the grid is tabbable — the * roving pattern — so Tab moves past the calendar rather than through 42 * days, and the arrow keys drive it from there. */ const [focused, setFocused] = React.useState(() => value ?? month); /** * Set when a keypress moves the selection, so the effect below knows to chase * focus into the new cell. Paging past a month boundary unmounts the cell the * user was standing on, which drops focus to `` — the very next keypress * would then go nowhere. Restoring in an effect rather than a frame callback * means focus is back before the next event can arrive. */ const chasingFocus = React.useRef(false); React.useEffect(() => { if (!chasingFocus.current) return; chasingFocus.current = false; gridRef.current ?.querySelector(`[data-cell="${cellIdFor(gridId, focused)}"]`) ?.focus(); }, [focused, gridRef, gridId, month]); const moveFocus = (next: Date) => { chasingFocus.current = true; setFocused(next); if (picker === 'date' && !isSameMonth(next, month)) onMonthChange(startOfMonth(next)); if (picker === 'month' && !isSameYear(next, month)) onMonthChange(startOfYear(next)); }; const step = (units: number, unit: 'day' | 'month' | 'year') => { if (unit === 'day') return addDays(focused, units); if (unit === 'month') return addMonths(focused, units); return addYears(focused, units); }; const handleKeyDown = (event: React.KeyboardEvent) => { const unit = picker === 'date' ? 'day' : picker === 'month' ? 'month' : 'year'; /* One row is a week of days, a quarter of months, or three years. */ const row = picker === 'date' ? 7 : 3; const moves: Record Date> = { ArrowLeft: () => step(-1, unit), ArrowRight: () => step(1, unit), ArrowUp: () => step(-row, unit), ArrowDown: () => step(row, unit), PageUp: () => (picker === 'date' ? addMonths(focused, -1) : addYears(focused, -1)), PageDown: () => (picker === 'date' ? addMonths(focused, 1) : addYears(focused, 1)), Home: () => addDays(focused, -((focused.getDay() - weekStart + 7) % 7)), End: () => addDays(focused, 6 - ((focused.getDay() - weekStart + 7) % 7)), }; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); if (!disabledDate?.(focused)) onSelect(focused); return; } const move = moves[event.key]; if (!move) return; /* Home and End are week-relative, so they only mean something in day view. */ if ((event.key === 'Home' || event.key === 'End') && picker !== 'date') return; event.preventDefault(); moveFocus(move()); }; return { focused, setFocused, handleKeyDown }; }