/** * The headless date range picker: a two-month grid, a two-step selection loop, two * typeable fields and an overlay — with no opinion about how any of it looks. * * Composes {@link useCalendar} in `'range'` mode and adds the parts a range picker * needs beyond a grid: the preview band that follows the pointer while the second * endpoint is unchosen (the calendar already maintains it), a field per endpoint, * and closing once the range is complete rather than on the first press. * * Imports no `react-native`, like every module it builds on. * * @example * ```tsx * const picker = useDateRangePicker({ onSelectRange: setRange, testID: 'stay' }); * * * * Pick dates * {picker.isOpen ? ( * <> * * * * Clear * * * ) : null} * ``` */ import { useRef, useState } from 'react'; import { addMonths, type DateRange, type ISODate, isRangeComplete } from '../lib/calendar'; import { type CalendarLocale, formatRangeLabel } from '../lib/calendar-format'; import type { WebKeyEvent } from '../lib/calendar-props'; import { type DayAvailability, isDayDisabled } from '../lib/calendar-selection'; import type { CalendarLabels, UseCalendarOptions, UseCalendarReturn } from '../lib/calendar-types'; import { type DateFieldFormat, fieldText, ISO_DATE_FIELD, type RangeField, resolveRangeCommit } 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'; // Declared ahead of the exports to satisfy `useExportsLast`. /** Both drafts. `null` for a field means it is not being edited right now. */ type Drafts = Record; const NO_DRAFTS: Drafts = { start: null, end: null }; /** The fallback names for the two fields, when no label overrides them. */ const FIELD_LABELS: Record = { start: 'Start date', end: 'End date' }; /** * The trigger's default name, which includes the current range. * * Same reasoning as the single picker's: a button that only says "Choose dates" * leaves a screen reader user no way to hear what is already chosen without * opening the panel. A half-open range reads as its one end, which is what * `formatRangeLabel` already does. */ function triggerLabel(range: DateRange, locale?: CalendarLocale): string { const label = formatRangeLabel(range, locale); return label === '' ? 'Choose dates' : `Choose dates, ${label}`; } /** * Everything one field's props need from the hook, as one object. * * Extracted so the hook body stays inside the per-function line cap, and so the * two fields are provably identical apart from which end they edit. */ type FieldContext = { testID: string | undefined; labels: DateRangePickerLabels | undefined; disabled: boolean; openOnFocus: boolean; format: DateFieldFormat; availability: DayAvailability; setDraft: (field: RangeField, text: string | null) => void; commit: (field: RangeField, close: boolean) => void; reveal: (field: RangeField, date: ISODate) => void; setOpen: (next: boolean) => void; onKeyDown: (event: WebKeyEvent) => void; }; /** * Which month to jump to so `date` is on screen for `field`. * * The end field aims to put its date in the *last* month shown rather than the * first, so a two-month view keeps the start visible beside it instead of paging * past it. */ function revealMonth(field: RangeField, date: ISODate, months: number): ISODate { return field === 'end' ? addMonths(date, -(months - 1)) : date; } /** One field's props: the same shape for both ends, parameterised by which one. */ function rangeFieldProps(context: FieldContext, field: RangeField, value: string): DateFieldProps { const { labels, format, availability } = context; const override = field === 'start' ? labels?.startField : labels?.endField; return buildFieldProps({ testID: context.testID, name: `${field}-field`, label: override ?? FIELD_LABELS[field], value, disabled: context.disabled, handlers: { onChangeText: (text) => { context.setDraft(field, text); // A complete, allowed date moves the grid as you type, so the fields and // the calendar never disagree about which months are on screen. const parsed = format.parse(text); if (parsed !== null && !isDayDisabled(parsed, availability)) context.reveal(field, parsed); }, onFocus: () => { if (context.openOnFocus) context.setOpen(true); }, onBlur: () => context.commit(field, false), onSubmit: () => context.commit(field, true), onKeyDown: context.onKeyDown, }, }); } /** Accessible names for the picker's own parts, on top of the calendar's. */ export type DateRangePickerLabels = CalendarLabels & { /** The disclosure button. The current range is appended to the default. */ trigger?: string; /** The panel holding the calendar. */ panel?: string; startField?: string; endField?: string; clear?: string; /** The backdrop. */ dismiss?: string; }; /** Options for {@link useDateRangePicker}: the calendar's, plus the overlay and fields. */ export type UseDateRangePickerOptions = Omit & { /** Controlled open state. Omit to let the hook own it. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** * Closes the panel once both ends are chosen — not on the first press, which * only starts the range. Default `true`. */ closeOnComplete?: boolean; /** Focusing a 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 both fields, and forces the clear button off. */ disabled?: boolean; /** How each field's text converts to a date and back. Default ISO both ways. */ format?: DateFieldFormat; labels?: DateRangePickerLabels; }; /** What {@link useDateRangePicker} returns. */ export type UseDateRangePickerReturn = DateOverlayGetters & { /** The grid, with every calendar getter. Render it inside the panel. */ calendar: UseCalendarReturn; isOpen: boolean; open: () => void; close: () => void; toggle: () => void; selectedRange: DateRange; /** Whether both ends are chosen. A half-open range is not complete. */ isComplete: boolean; /** The text in the start field: its draft while typing, else the formatted start. */ startValue: string; /** The text in the end field: its draft while typing, else the formatted end. */ endValue: string; /** Empties the range and both fields. */ clear: () => void; getStartFieldProps: () => DateFieldProps; getEndFieldProps: () => DateFieldProps; }; /** * A date range picker: two months, a two-step selection loop, a field per endpoint * and an overlay. * * Uncontrolled by default. Pass `selectedRange`/`onSelectRange` to drive the value * and `open`/`onOpenChange` to drive the panel. */ export function useDateRangePicker(options: UseDateRangePickerOptions = {}): UseDateRangePickerReturn { const { closeOnComplete = true, disabled = false, format = ISO_DATE_FIELD, locale, testID } = options; const { value: isOpen, setValue: setOpen } = useControlledValue( options.open, options.defaultOpen ?? false, options.onOpenChange, ); const [drafts, setDrafts] = useState(NO_DRAFTS); // Set while a field commit is in flight, so the range it sets 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 months = Math.max(1, options.numberOfMonths ?? 2); const calendar = useCalendar({ ...options, mode: 'range', numberOfMonths: months, onSelectRange: (next) => { setDrafts(NO_DRAFTS); options.onSelectRange?.(next); if (closeOnComplete && isRangeComplete(next) && !fromField.current) setOpen(false); }, }); const range = calendar.selectedRange; const clear = () => { calendar.clearSelection(); setDrafts(NO_DRAFTS); }; const setDraft = (field: RangeField, text: string | null) => setDrafts((current) => ({ ...current, [field]: text })); const reveal = (field: RangeField, date: ISODate) => calendar.goToMonth(revealMonth(field, date, months)); /** * Commits one field's text. `thenClose` is false on blur, true on the return * key: submitting is a deliberate "done", blurring may be a press on a day. */ const commitText = (field: RangeField, thenClose: boolean) => { const text = drafts[field]; // Not edited, so there is nothing to commit — and nothing to snap back. The // return key still counts as "done" and closes, matching the single picker. if (text === null) { if (thenClose) setOpen(false); return; } const commit = resolveRangeCommit({ range, field, text, format, availability: options }); // Text that does not parse leaves the range alone and snaps the field back to // it, rather than quietly discarding an end the user never asked to remove. setDraft(field, null); if (commit.kind === 'reject') return; fromField.current = true; calendar.setRange(commit.range); fromField.current = false; // The typed date, not the committed one: a reversed pair is normalised on the // way in, and the user should still see the day they just typed. const typed = format.parse(text); if (typed !== null) reveal(field, typed); if (thenClose) setOpen(false); }; const close = () => setOpen(false); const toggle = () => setOpen(!isOpen); const onKeyDown = escapeHandler(isOpen, close); const startValue = fieldText(drafts.start, range.start, format); const endValue = fieldText(drafts.end, range.end, format); const fields: FieldContext = { testID, labels: options.labels, disabled, format, // The options *are* a `DayAvailability` — the same three optional fields — so // the bounds are read straight off them rather than copied into a second object. availability: options, openOnFocus: options.openOnFocus ?? false, setDraft, commit: commitText, reveal, setOpen, onKeyDown, }; return { calendar, isOpen, open: () => setOpen(true), close, toggle, selectedRange: range, isComplete: isRangeComplete(range), startValue, endValue, clear, getStartFieldProps: () => rangeFieldProps(fields, 'start', startValue), getEndFieldProps: () => rangeFieldProps(fields, 'end', endValue), ...buildOverlayGetters({ testID, labels: options.labels, defaultTriggerLabel: triggerLabel(range, locale), defaultClearLabel: 'Clear dates', disabled, isOpen, modal: options.modal ?? true, dismissable: options.dismissable ?? true, // Either end counts: a half-open range is still something to clear. hasSelection: range.start !== null || range.end !== null, toggle, close, clear, onKeyDown, }), }; }