/** * A headless date picker: one calendar, one text field, and the open/close state * that ties them together. * * Everything visual is yours. This hook owns the parts that are fiddly to get * right — a field that stays typeable while it disagrees with the selection, a * commit that refuses a day the grid would also refuse, and a blur that does not * steal the click that caused it. * * Renders nothing, imports no `react-native`, and composes {@link useCalendar} * rather than reimplementing it: the grid is reached through `calendar`, with all * of its getters. * * @example * ```tsx * const picker = useDatePicker({ onSelectDate: setDate, testID: 'due' }); * * * Pick * * {picker.isOpen ? ( * * * * * Clear * * * ) : null} * ``` */ import { useRef, useState } from 'react'; import type { ISODate } from '../lib/calendar'; import { type CalendarLocale, formatDateLabel } from '../lib/calendar-format'; import { isDayDisabled } from '../lib/calendar-selection'; import type { CalendarLabels, UseCalendarOptions, UseCalendarReturn } from '../lib/calendar-types'; import { type DateFieldFormat, fieldText, ISO_DATE_FIELD, resolveFieldCommit } from '../lib/date-field'; import { buildFieldProps, buildOverlayGetters, type DateFieldProps, type DateOverlayGetters, escapeHandler, } from '../lib/date-picker-props'; import { useCalendar } from './use-calendar'; import { useControlledValue } from './use-controlled'; /** * The trigger's default name, which includes the current date. * * A button that only says "Choose date" gives a screen reader user no way to hear * what is already selected without opening the panel, so the date is appended * when there is one — spoken (`'August 5, 2026'`), not as the ISO string. */ function triggerLabel(selected: ISODate | null, locale?: CalendarLocale): string { return selected === null ? 'Choose date' : `Choose date, ${formatDateLabel(selected, locale)}`; } /** Accessible names for the picker's own parts, on top of the calendar's. */ export type DatePickerLabels = CalendarLabels & { /** The disclosure button. The current date is appended to the default. */ trigger?: string; /** The panel holding the calendar. */ panel?: string; /** The text field. */ field?: string; clear?: string; /** The backdrop. */ dismiss?: string; }; /** Options for {@link useDatePicker}: the calendar's, plus the overlay and field. */ export type UseDatePickerOptions = Omit & { /** Controlled open state. Omit to let the hook own it. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Pressing a day closes the panel. Default `true`. */ closeOnSelect?: boolean; /** Focusing the field opens the panel. Default `false`. */ openOnFocus?: boolean; /** * Whether the panel traps focus, which is what the modal flags claim. Default * `true`; pass `false` for a calendar rendered inline. */ modal?: boolean; /** Whether a press on the backdrop closes. Default `true`. */ dismissable?: boolean; /** Disables the trigger and the field, and forces the clear button off. */ disabled?: boolean; /** How the field's text converts to a date and back. Default ISO both ways. */ format?: DateFieldFormat; labels?: DatePickerLabels; }; /** What {@link useDatePicker} returns. */ export type UseDatePickerReturn = DateOverlayGetters & { /** The grid, with every calendar getter. Render it inside the panel. */ calendar: UseCalendarReturn; isOpen: boolean; open: () => void; close: () => void; toggle: () => void; selectedDate: ISODate | null; /** The text in the field: the draft while typing, else the formatted selection. */ inputValue: string; /** Empties the selection and the field. */ clear: () => void; getFieldProps: () => DateFieldProps; }; /** * A single-date picker with a calendar, a typeable field and an overlay — and no * opinion about how any of it looks. * * Uncontrolled by default. Pass `selectedDate`/`onSelectDate` to drive the value * and `open`/`onOpenChange` to drive the panel. */ export function useDatePicker(options: UseDatePickerOptions = {}): UseDatePickerReturn { const { closeOnSelect = true, disabled = false, format = ISO_DATE_FIELD, locale, testID } = options; const { value: isOpen, setValue: setOpen } = useControlledValue( options.open, options.defaultOpen ?? false, options.onOpenChange, ); // `null` means "not being edited": the field then shows the formatted selection. const [draft, setDraft] = useState(null); // Set while a field commit is in flight, so the selection it makes does not // also close the panel — a blur-commit runs as focus moves *into* the panel, // and closing there would swallow the press that caused it. const fromField = useRef(false); const calendar = useCalendar({ ...options, mode: 'single', onSelectDate: (date) => { setDraft(null); options.onSelectDate?.(date); if (closeOnSelect && date !== null && !fromField.current) setOpen(false); }, }); const selectedDate = calendar.selectedDate; const clear = () => { calendar.clearSelection(); setDraft(null); }; /** * Commits what was typed. `thenClose` is false on blur, true on the return key: * submitting is a deliberate "done", blurring may be a press on a day. * * A field nobody edited commits nothing: without the `draft` guard, merely * tabbing through an empty field would read as "cleared" and fire * `onSelectDate(null)` at a caller who never touched it. */ const commitText = (text: string, thenClose: boolean) => { if (draft === null) { if (thenClose) setOpen(false); return; } const commit = resolveFieldCommit({ text, format, availability: options }); // Unparseable text leaves the selection alone and snaps the field back to it, // rather than quietly discarding a date the user never asked to remove. setDraft(null); if (commit.kind === 'reject') return; if (commit.kind === 'clear') { calendar.clearSelection(); return; } fromField.current = true; calendar.selectDate(commit.date); fromField.current = false; calendar.goToMonth(commit.date); if (thenClose) setOpen(false); }; const onKeyDown = escapeHandler(isOpen, () => setOpen(false)); const value = fieldText(draft, selectedDate, format); return { calendar, isOpen, open: () => setOpen(true), close: () => setOpen(false), toggle: () => setOpen(!isOpen), selectedDate, inputValue: value, clear, getFieldProps: () => buildFieldProps({ testID, name: 'field', label: options.labels?.field ?? 'Date', value, disabled, handlers: { onChangeText: (text) => { setDraft(text); const parsed = format.parse(text); // A complete, allowed date moves the grid as you type, so the field // and the calendar never disagree about which month is on screen. The // options *are* a `DayAvailability`, so the bounds are read off them. if (parsed !== null && !isDayDisabled(parsed, options)) calendar.goToMonth(parsed); }, onFocus: () => { if (options.openOnFocus ?? false) setOpen(true); }, onBlur: () => commitText(value, false), onSubmit: () => commitText(value, true), onKeyDown, }, }), ...buildOverlayGetters({ testID, labels: options.labels, defaultTriggerLabel: triggerLabel(selectedDate, locale), defaultClearLabel: 'Clear date', disabled, isOpen, modal: options.modal ?? true, dismissable: options.dismissable ?? true, hasSelection: selectedDate !== null, toggle: () => setOpen(!isOpen), close: () => setOpen(false), clear, onKeyDown, }), }; }