/** * The headless calendar grid: month navigation, selection, a keyboard cursor, and * the prop getters that carry all of it onto a consumer's own elements. * * This hook renders nothing and styles nothing. It owns the parts that are easy to * get subtly wrong — DST-safe date arithmetic, a roving tabindex, focus that * follows the cursor across a month boundary, range previews — and leaves every * visual decision to the caller. * * It imports no `react-native`, so it loads in a bare jsdom test. That is also why * `isRTL` is an option rather than a `useIsRTL()` call inside: pass `useIsRTL()` * from the component that renders the grid to make the horizontal arrows mirror. * * @example * ```tsx * const calendar = useCalendar({ onSelectDate: setDate, testID: 'birthday' }); * * * ‹ * {calendar.months.map(({ month, label, weeks }) => ( * * {label} * * {calendar.weekdays.map((weekday) => ( * {weekday.narrow} * ))} * * {weeks.map((week, index) => ( * * {week.map((day) => ( * * {day.day} * * ))} * * ))} * * ))} * › * * ``` */ import { useMemo, useRef, useState } from 'react'; import { addMonths, clampISO, type DateRange, type ISODate, normalizeRange, startOfMonth, todayISO } from '../lib/calendar'; import { buildGetters, type FocusableNode, type WebKeyEvent } from '../lib/calendar-props'; import { type DayAvailability, EMPTY_RANGE, isCalendarNavigationKey, isDayDisabled, nextFocusedDate, nextRangeSelection, nextSingleSelection, previewRange, } from '../lib/calendar-selection'; import type { UseCalendarOptions, UseCalendarReturn } from '../lib/calendar-types'; import { buildMonth, buildWeekdays, canStepBack, canStepForward, focusTargetFor, isInView, monthsLabel, visibleMonths, } from '../lib/calendar-view'; import { useControlledValue } from './use-controlled'; /** The day nodes on screen, and the pending request to move real focus into one. */ type FocusCursor = { registerNode: (date: ISODate, node: FocusableNode | null) => void; requestFocus: (date: ISODate) => void; }; /** * Moves real focus to follow the grid cursor. * * Without this an arrow key would move only the *visual* cursor: the platform's * focus would stay on whichever cell was tabbed into, so a screen reader would * announce nothing and `Enter` would act on the wrong day. * * Two paths, because the destination may not exist yet — stepping off the end of * a month mounts the next one. A cell already on screen is focused straight away; * one that is not is remembered, and focused by its own `ref` callback the moment * it attaches. No effect either way, so focus lands in the same commit. */ function useFocusCursor(): FocusCursor { const nodes = useRef(new Map()); const pending = useRef(null); const registerNode = (date: ISODate, node: FocusableNode | null) => { if (node === null) { nodes.current.delete(date); return; } nodes.current.set(date, node); // The cell the cursor was waiting for has just arrived. if (pending.current !== date) return; pending.current = null; node.focus?.(); }; const requestFocus = (date: ISODate) => { const node = nodes.current.get(date); if (node?.focus === undefined) { pending.current = date; return; } node.focus(); }; return { registerNode, requestFocus }; } /** The selection in both shapes, plus the transitions that change it. */ type SelectionState = { selectedDate: ISODate | null; selectedRange: DateRange; /** The union `decorateDay` takes: the range in range mode, the day in single mode. */ selected: ISODate | null | DateRange; selectDate: (date: ISODate) => void; setRange: (range: DateRange) => void; clearSelection: () => void; }; /** * Both selection shapes behind one interface. * * They stay separate fields rather than one polymorphic `value` so a consumer of * either mode gets a precisely typed callback, instead of a union to narrow at * every call site. The unused shape reads as empty rather than stale. */ function useSelection(options: UseCalendarOptions, availability: DayAvailability): SelectionState { const { mode = 'single', deselectable = false } = options; const single = useControlledValue(options.selectedDate, options.defaultSelectedDate ?? null, options.onSelectDate); const range = useControlledValue(options.selectedRange, options.defaultSelectedRange ?? EMPTY_RANGE, options.onSelectRange); const isRange = mode === 'range'; return { selectedDate: isRange ? null : single.value, selectedRange: isRange ? range.value : EMPTY_RANGE, selected: isRange ? range.value : single.value, selectDate: (date) => { // Checked here and not only in the press handler, so a programmatic // `selectDate` cannot put an out-of-bounds day into the selection either. if (isDayDisabled(date, availability)) return; if (isRange) range.setValue(nextRangeSelection(range.value, date)); else single.setValue(nextSingleSelection(single.value, date, deselectable)); }, setRange: (next) => { if (!isRange) return; // All-or-nothing, like `selectDate`: a range with one blocked endpoint is // refused outright rather than silently trimmed to its allowed part, which // would hand back a range nobody asked for. const blocked = [next.start, next.end].some((date) => date !== null && isDayDisabled(date, availability)); if (blocked) return; range.setValue(normalizeRange(next)); }, clearSelection: () => { if (isRange) range.setValue(EMPTY_RANGE); else single.setValue(null); }, }; } /** The months on screen, and the stepping that changes them. */ type MonthViewState = { view: ISODate[]; month: ISODate; canGoToPreviousMonth: boolean; canGoToNextMonth: boolean; goToMonth: (month: ISODate) => void; goToPreviousMonth: () => void; goToNextMonth: () => void; }; /** * Which months are on screen. * * Stepping moves by one month even when several are shown, which is what a * two-month range picker wants: paging by two would carry away both of the months * the user was just comparing. */ function useMonthView(options: UseCalendarOptions, anchor: ISODate): MonthViewState { const controlled = useControlledValue(options.month, anchor, options.onMonthChange); const view = visibleMonths(controlled.value, Math.max(1, options.numberOfMonths ?? 1)); const month = view[0] ?? startOfMonth(controlled.value); const last = view.at(-1) ?? month; // Anchored to the 1st on the way out, so `onMonthChange` never hands back a // mid-month date the caller has to normalise itself. const goToMonth = (next: ISODate) => controlled.setValue(startOfMonth(next)); return { view, month, canGoToPreviousMonth: canStepBack(month, options.minDate), canGoToNextMonth: canStepForward(last, options.maxDate), goToMonth, goToPreviousMonth: () => goToMonth(addMonths(month, -1)), goToNextMonth: () => goToMonth(addMonths(month, 1)), }; } /** * A month grid with selection, keyboard navigation and accessible props — and no * opinion about how any of it looks. * * Uncontrolled by default. Pass `selectedDate`/`onSelectDate` (or the `Range` * pair) to drive it from your own state, and `month`/`onMonthChange` to drive the * view. */ export function useCalendar(options: UseCalendarOptions = {}): UseCalendarReturn { const { weekStartsOn = 0, locale, testID } = options; const today = options.today ?? todayISO(); const availability = { minDate: options.minDate, maxDate: options.maxDate, isDateDisabled: options.isDateDisabled }; const selection = useSelection(options, availability); // The month first shown: whatever the caller asked for, else the month holding // the selection, else the month holding today. const anchor = options.defaultMonth ?? selection.selectedDate ?? selection.selectedRange.start ?? today; const view = useMonthView(options, anchor); const cursor = useFocusCursor(); const [stored, setStored] = useState(() => clampISO(anchor, options.minDate, options.maxDate)); const [hovered, setHovered] = useState(null); const focusedDate = focusTargetFor(stored, view.view); /** Moves the cursor, pulling the view along if the target is off screen. */ const focusDate = (date: ISODate) => { setStored(date); if (!isInView(date, view.view)) view.goToMonth(date); cursor.requestFocus(date); }; const onKeyDown = (event: WebKeyEvent) => { if (!isCalendarNavigationKey(event.key)) return; const next = nextFocusedDate({ from: focusedDate, key: event.key, shiftKey: event.shiftKey ?? false, weekStartsOn, minDate: options.minDate, maxDate: options.maxDate, isRTL: options.isRTL ?? false, }); if (next === null) return; // Only now: an unhandled key must keep its default (Tab still leaves the grid). event.preventDefault(); focusDate(next); }; const weekdays = useMemo(() => buildWeekdays(weekStartsOn, locale), [weekStartsOn, locale]); const decoration = { today, selection: selection.selected, // Range mode only: the band that follows the pointer, or the keyboard cursor // when there is no pointer, while the second endpoint is unchosen. preview: options.mode === 'range' ? previewRange(selection.selectedRange, hovered ?? focusedDate) : null, focused: focusedDate, availability, weekendDays: options.weekendDays, }; return { ...selection, months: view.view.map((month) => buildMonth(month, { weekStartsOn, fixedWeeks: options.fixedWeeks ?? false, locale, decoration }), ), weekdays, month: view.month, focusedDate, today, canGoToPreviousMonth: view.canGoToPreviousMonth, canGoToNextMonth: view.canGoToNextMonth, goToPreviousMonth: view.goToPreviousMonth, goToNextMonth: view.goToNextMonth, goToMonth: view.goToMonth, goToToday: () => focusDate(clampISO(today, options.minDate, options.maxDate)), ...buildGetters({ testID, locale, rootLabel: options.labels?.calendar ?? monthsLabel(view.view, locale), navLabels: { previous: options.labels?.previousMonth ?? 'Previous month', next: options.labels?.nextMonth ?? 'Next month', }, canGoToPreviousMonth: view.canGoToPreviousMonth, canGoToNextMonth: view.canGoToNextMonth, goToPreviousMonth: view.goToPreviousMonth, goToNextMonth: view.goToNextMonth, dayHandlers: { // Selection and the cursor move together: clicking a day makes it the // keyboard's day too, so the next arrow key steps from where you clicked. onPress: (date) => { selection.selectDate(date); focusDate(date); }, // The platform moved focus itself (a click, or Tab into the grid), so the // cursor follows rather than fighting it. onFocus: (date) => setStored(date), onHoverIn: (date) => setHovered(date), onHoverOut: () => setHovered(null), onKeyDown, registerNode: cursor.registerNode, }, }), }; } // The option and result types live in `../lib/calendar-types` to keep this file // under the line cap, but this module is where consumers should reach them. export type { CalendarLabels, CalendarMode, CalendarState, UseCalendarOptions, UseCalendarReturn, } from '../lib/calendar-types';