// ~/src/siteplane/components/date-range-picker/date-range-picker.tsx "use client"; import { type CalendarDate, type DateValue } from "@internationalized/date"; import { CalendarDaysIcon } from "lucide-react"; import * as React from "react"; import { Button as DateRangeButtonPrimitive, DateFieldContext, DateFieldStateContext, DateInput as DateRangeInputPrimitive, DateRangePicker as DateRangePickerPrimitive, type DateRangePickerState, DateSegment as DateRangeSegmentPrimitive, Dialog as DateRangeDialogPrimitive, Group as DateRangeGroupPrimitive, Popover as DateRangePopoverPrimitive, type RangeValue, useLocale, useSlottedContext, } from "react-aria-components"; import { isInvalidAriaValue } from "../../system/form/aria-invalid"; import { useFormReset } from "../../system/form/use-form-reset"; import { normalizeFormattedText } from "../../system/locale/formatted-text"; import { SiteplaneLocaleProvider } from "../../system/locale/siteplane-locale-provider"; import { type SiteplaneControlSize, SiteplaneUIPortalScope, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { surfaceClassNames, type FormControlTextSize, type FormControlVariant, } from "../../system/styles/surface"; import { cn } from "../../system/utils/cn"; import { Button } from "../button"; import { Calendar } from "../calendar"; import { calendarMonthBounds, type CalendarOptions, type DateRange, fromNativeDate, isCalendarDayDisabled, toNativeDate, } from "../calendar/calendar"; import { type FieldControlBridge, hasClearedSegment, type SegmentedValue, useFieldControlBridge, useFieldControlProxy, useFieldErrorSegments, useSegmentedValue, } from "../field/field-control-bridge"; import { Input } from "../input"; import { ExternalInputOwner } from "../input/input"; import { InputGroup, InputGroupAddon, InputGroupText } from "../input-group"; // The shared date segment styles are owned by DateField. import "../date-field/date-field.css"; import "./date-range-picker.css"; // The range shape is the Calendar's, so a range moves between the picker and // a range Calendar as it is. export type { DateRange } from "../calendar/calendar"; export interface DateRangePickerProps { "aria-describedby"?: string | undefined; "aria-invalid"?: React.AriaAttributes["aria-invalid"]; "aria-label"?: string; "aria-labelledby"?: string | undefined; calendarProps?: CalendarOptions; className?: string; defaultValue?: DateRange; disabled?: boolean; endLabel?: string; endName?: string; form?: string; /** * `action-end` ends the field with the button that opens the calendar; * `none` leaves the range to typing, as DateField's `iconMode="none"` does, * for a range beside a calendar of its own. */ iconMode?: "action-end" | "none" | undefined; /** * The error a required picker shows while its range is incomplete, such as * after a segment is cleared. Default: "Enter a complete date range." */ incompleteLabel?: string; locale?: string; onValueChange?: (value: DateRange | undefined) => void; openPickerLabel?: string; readOnly?: boolean; required?: boolean; size?: SiteplaneControlSize; startLabel?: string; startName?: string; textSize?: FormControlTextSize; value?: DateRange | null; variant?: FormControlVariant; } const actionSizes = { md: "icon-xs", lg: "icon-xs", sm: "icon-2xs", xl: "icon-sm", } as const satisfies Record< SiteplaneControlSize, "icon-2xs" | "icon-xs" | "icon-sm" >; function toCalendarDate(value: Date | null | undefined): CalendarDate | null { return value ? fromNativeDate(value) : null; } function toOptionalNativeDate( value: DateValue | null | undefined, ): Date | undefined { return value ? toNativeDate(value) : undefined; } function toInternalRange( value: DateRange | null | undefined, ): RangeValue | null { const start = toCalendarDate(value?.from); const end = toCalendarDate(value?.to); return start && end ? { end, start } : null; } function toNativeRange( value: RangeValue | null | undefined, ): DateRange | undefined { if (!value?.start && !value?.end) return undefined; return { from: toOptionalNativeDate(value.start), to: toOptionalNativeDate(value.end), }; } function formatDate(value: DateValue | null | undefined): string { if (!value) return ""; return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`; } function formatRange(value: RangeValue | null): string { return value?.start && value.end ? `${formatDate(value.start)}/${formatDate(value.end)}` : ""; } type ClearedEndpoints = { end: boolean; start: boolean }; function DateRangeHiddenInputs({ cleared, controlled, controlledInvalid, controlRef, defaultValue, endName, field, focusTarget, form, required, segmented, startName, state, }: { cleared: ClearedEndpoints; controlled: boolean; controlledInvalid: boolean | undefined; controlRef: React.RefObject; defaultValue: DateRange | undefined; endName: string | undefined; field: FieldControlBridge; focusTarget: () => HTMLElement | null; form: string | undefined; required: boolean | undefined; segmented: SegmentedValue>; startName: string | undefined; state: DateRangePickerState; }): React.ReactElement { const startInputRef = useFormReset(form, () => { if (!controlled) state.setValue(toInternalRange(defaultValue)); }); // A date with a cleared segment has no value, although React Aria still // holds the last complete one. const startValue = cleared.start ? null : state.value.start; const endValue = cleared.end ? null : state.value.end; const start = formatDate(startValue); const end = formatDate(endValue); React.useLayoutEffect(() => { const ownerValue = state.value.start && state.value.end ? { end: state.value.end, start: state.value.start } : null; segmented.sync({ cleared: cleared.start || cleared.end, effective: startValue && endValue ? { end: endValue, start: startValue } : null, value: ownerValue, }); }); const proxyProps = useFieldControlProxy({ controlled: controlledInvalid !== undefined, disabled: field.disabled, displayValidation: state.displayValidation, focusTarget, form, name: undefined, onRevalidate: state.commitValidation, realtimeValidation: state.realtimeValidation, required, value: start && end ? `${start}/${end}` : "", }); useFieldErrorSegments(controlRef, { controlled: controlledInvalid !== undefined, ownerInvalid: state.isInvalid, }); return ( <> {proxyProps ? ( ) : null} ); } // React Aria names each date segment with its date field's name and the range // label, such as "month, Start Date, Trip dates". A label replaces the // localized Start Date or End Date field name. function DateRangeEndpoint({ label, onClearedChange, slot, }: { label: string | undefined; onClearedChange: (slot: "end" | "start", cleared: boolean) => void; slot: "end" | "start"; }): React.ReactElement { const fieldProps = useSlottedContext(DateFieldContext, slot); return ( {(segment) => ( onClearedChange(slot, cleared)} segment={segment} /> )} ); } // The start date, the dash and the end date read in the locale's direction, // which React Aria's arrow keys follow, whatever the page's: an en-US range // stays 01/01/2024 – 10/14/2026 in a right-to-left page. The dir attribute // also isolates the row from the text around it; date-range-picker.css keeps // the field's insets on the field's sides. function DateRangeSegments({ children, }: { children: React.ReactNode; }): React.ReactElement { const { direction } = useLocale(); return (
{children}
); } // Each date of the range keeps its own field state inside its DateInput; the // segments tell the picker whether that date shows a cleared segment. function DateRangeSegment({ onClearedChange, segment, }: { onClearedChange: (cleared: boolean) => void; segment: React.ComponentProps["segment"]; }): React.ReactElement { const cleared = hasClearedSegment(React.useContext(DateFieldStateContext)); React.useLayoutEffect(() => onClearedChange(cleared)); // The segment shows the text that the server's ICU and the browser's agree // on, as the Date Field's do (date-field.tsx), so the prerendered range // hydrates. return ( {({ text }) => normalizeFormattedText(text)} ); } export function DateRangePicker({ "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, calendarProps, className, defaultValue, disabled, endLabel, endName, form, iconMode = "action-end", incompleteLabel = "Enter a complete date range.", locale, onValueChange, openPickerLabel = "Open date range calendar", readOnly, required, size, startLabel, startName, textSize, value, variant = "default", }: DateRangePickerProps): React.ReactElement { const controlled = value !== undefined; const resolvedSize = useResolvedControlSize(size); const controlRef = React.useRef(null); const field = useFieldControlBridge({ "aria-describedby": ariaDescribedBy, "aria-labelledby": ariaLabelledBy, disabled, }); const fieldAriaLabel = field.labelledBy ? undefined : ariaLabel; // React Aria validates typed dates unless the application controls the // invalid state through aria-invalid or an invalid Field. const controlledInvalid = ariaInvalid !== undefined ? isInvalidAriaValue(ariaInvalid) : field.invalid || undefined; // A cleared segment empties the range's value. const [cleared, setCleared] = React.useState({ end: false, start: false, }); const onClearedChange = React.useCallback( (slot: "end" | "start", next: boolean) => setCleared((current) => current[slot] === next ? current : { ...current, [slot]: next }, ), [], ); const segmented = useSegmentedValue>({ controlled, defaultValue: toInternalRange(defaultValue), incompleteMessage: incompleteLabel, onValueChange: (nextValue) => onValueChange?.( nextValue?.start && nextValue.end ? toNativeRange(nextValue) : undefined, ), required, serialize: formatRange, value: toInternalRange(value), }); // React Aria's optional props reject `undefined` under // exactOptionalPropertyTypes, so unset values are omitted. const picker = ( isCalendarDayDisabled(toNativeDate(date), calendarProps?.disabled) } isDisabled={field.disabled} {...(controlledInvalid === undefined ? {} : { isInvalid: controlledInvalid })} {...(readOnly === undefined ? {} : { isReadOnly: readOnly })} {...(required === undefined ? {} : { isRequired: required })} {...calendarMonthBounds(calendarProps ?? {})} // Typed segments start from the calendar's today, `todayDate` when set. {...(calendarProps?.todayDate === undefined ? {} : { placeholderValue: fromNativeDate(calendarProps.todayDate) })} onChange={segmented.onOwnerChange} shouldCloseOnSelect shouldForceLeadingZeros validate={segmented.validate} > {({ isInvalid, state }) => ( <> {iconMode === "action-end" ? ( ) : null} {/* Without its button the range opens no calendar: React Aria's picker works with its date fields alone. */} {iconMode === "action-end" ? ( ) : null} controlRef.current?.querySelector( '[role="spinbutton"], [role="textbox"]', ) ?? null } form={form} required={required} segmented={segmented} startName={startName} state={state} /> )} ); return locale ? ( {picker} ) : ( picker ); } // ~/src/siteplane/components/date-range-picker/date-range-picker.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The heights are the Input scale (28, 32, 36 and 48 px), so the picker is as * tall as the Input, Select, Button, DateField and TimeField of the same size. */ .sp-date-range-control { --date-range-picker-control-height: var(--sp-rw-control-md); --date-range-picker-segment-start: calc( var(--sp-rw-space-3) - var(--input-segment-padding-inline) ); height: var(--date-range-picker-control-height); } .sp-date-range-control[data-size="sm"] { --date-range-picker-control-height: var(--sp-rw-control-sm); --date-range-picker-segment-start: calc( var(--sp-rw-space-2-5) - var(--input-segment-padding-inline) ); } .sp-date-range-control[data-size="lg"] { --date-range-picker-control-height: var(--sp-rw-control-lg); } .sp-date-range-control[data-size="xl"] { --date-range-picker-control-height: var(--sp-rw-control-xl); --date-range-picker-segment-start: calc( var(--sp-rw-space-4) - var(--input-segment-padding-inline) ); } /* The segment row holds the start date, the dash and the end date, in the * locale's direction (its dir). The insets belong to the field, which takes * the page's direction: it pads its start, and without the calendar button * (iconMode="none") its end too, as a DateField without its button does, so * a range as wide as its segments is balanced. */ .sp-date-range-control { padding-inline-start: var(--date-range-picker-segment-start); } .sp-date-range-control[data-icon-mode="none"] { padding-inline-end: var(--date-range-picker-segment-start); } .sp-date-range-picker-segments { display: flex; flex: none; align-items: center; align-self: stretch; } /* The surface (border, radius, shadow) is the floating-md recipe that * design-rework.css draws for the popover's data-slot. */ .sp-date-range-popover { z-index: 50; width: max-content; padding: var(--sp-rw-space-4); outline: none; transform-origin: var(--trigger-anchor-point); } .sp-date-range-popover[data-entering] { animation: sp-date-range-popover-enter var(--motion-duration-overlay) var(--motion-ease-standard) both; } .sp-date-range-popover[data-exiting] { animation: sp-date-range-popover-exit var(--motion-duration-fast) var(--motion-ease-soft-in-out) both; } @keyframes sp-date-range-popover-enter { from { opacity: 0; transform: scale(0.98); } } @keyframes sp-date-range-popover-exit { to { opacity: 0; transform: scale(0.98); } } @media (prefers-reduced-motion: reduce) { .sp-date-range-popover[data-entering], .sp-date-range-popover[data-exiting] { animation-duration: 1ms; } } html[data-motion-reduce="true"] .sp-date-range-popover[data-entering], html[data-motion-reduce="true"] .sp-date-range-popover[data-exiting], [data-sp-motion="reduced"] .sp-date-range-popover[data-entering], [data-sp-motion="reduced"] .sp-date-range-popover[data-exiting] { animation-duration: 1ms; } } // ~/src/siteplane/components/date-range-picker/index.ts export * from "./date-range-picker";