// ~/src/siteplane/components/date-time-picker/date-time-picker.tsx "use client"; import { CalendarDateTime, type DateValue, Time, toCalendarDateTime as toCalendarDateTimeAt, } from "@internationalized/date"; import { CalendarDaysIcon, ClockIcon } from "lucide-react"; import * as React from "react"; import { Button as DateTimeButtonPrimitive, DateFieldStateContext, DateInput as DateTimeInputPrimitive, DatePicker as DateTimePickerPrimitive, type DatePickerState, DateSegment as DateTimeSegmentPrimitive, Dialog as DateTimeDialogPrimitive, DialogTrigger as TimeSelectorTriggerPrimitive, Group as DateTimeGroupPrimitive, Popover as DateTimePopoverPrimitive, useLocale, } 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 { formControlTextClassNames, 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, 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"; import { type TimeFieldHourCycle, TimeFieldOptionColumn, useHourOptionLabels, } from "../time-field/time-field"; // The shared date segment styles are owned by DateField. import "../date-field/date-field.css"; import "./date-time-picker.css"; export type DateTimePickerValue = { date: Date; time: { hour: number; minute: number; }; }; export interface DateTimePickerProps { "aria-describedby"?: string | undefined; "aria-invalid"?: React.AriaAttributes["aria-invalid"]; "aria-label"?: string; "aria-labelledby"?: string | undefined; calendarProps?: CalendarOptions; className?: string; defaultValue?: DateTimePickerValue; disabled?: boolean; form?: string; /** * The clock the time segments and the time selector show: `24` (00 to 23), * or `12` with the locale's AM and PM. The value stays in 24-hour time. */ hourCycle?: TimeFieldHourCycle; /** * The error a required picker shows while its date and time are * incomplete, such as after a segment is cleared. Default: "Enter a * complete date and time." */ incompleteLabel?: string; locale?: string; minuteStep?: number; name?: string; onValueChange?: (value: DateTimePickerValue | undefined) => void; openDatePickerLabel?: string; openTimePickerLabel?: string; readOnly?: boolean; required?: boolean; size?: SiteplaneControlSize; textSize?: FormControlTextSize; value?: DateTimePickerValue | 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" >; const hourOptions = Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, "0"), ); function normalizeTime(value: DateTimePickerValue["time"]) { return { hour: Math.min(Math.max(Math.trunc(value.hour) || 0, 0), 23), minute: Math.min(Math.max(Math.trunc(value.minute) || 0, 0), 59), }; } function toCalendarDateTime( value: DateTimePickerValue | null | undefined, ): CalendarDateTime | null { if (!value) return null; const time = normalizeTime(value.time); return new CalendarDateTime( value.date.getFullYear(), value.date.getMonth() + 1, value.date.getDate(), time.hour, time.minute, ); } function toNativeValue( value: DateValue | null | undefined, ): DateTimePickerValue | undefined { if (!value || !("hour" in value)) return undefined; return { date: toNativeDate(value), time: { hour: value.hour, minute: value.minute }, }; } function formatValue(value: DateValue | null | undefined): string { const nativeValue = toNativeValue(value); if (!nativeValue) return ""; return `${String(nativeValue.date.getFullYear()).padStart(4, "0")}-${String(nativeValue.date.getMonth() + 1).padStart(2, "0")}-${String(nativeValue.date.getDate()).padStart(2, "0")}T${String(nativeValue.time.hour).padStart(2, "0")}:${String(nativeValue.time.minute).padStart(2, "0")}`; } /** * The Calendar's month bounds as date-time bounds of the picker: from the * start of the first allowed day to 23:59 on the last. */ function dateTimeBounds(calendarProps: CalendarOptions | undefined): { maxValue?: CalendarDateTime; minValue?: CalendarDateTime; } { const { maxValue, minValue } = calendarMonthBounds(calendarProps ?? {}); return { ...(maxValue === undefined ? {} : { maxValue: toCalendarDateTimeAt(maxValue, new Time(23, 59)) }), ...(minValue === undefined ? {} : { minValue: toCalendarDateTimeAt(minValue) }), }; } type DateTimeSegmentValue = React.ComponentProps< typeof DateTimeSegmentPrimitive >["segment"]; function isTimeSegment(segment: DateTimeSegmentValue | undefined): boolean { return ( segment?.type === "hour" || segment?.type === "minute" || segment?.type === "second" || segment?.type === "dayPeriod" || segment?.type === "timeZoneName" ); } function getSegmentPart( segment: DateTimeSegmentValue, segments: readonly DateTimeSegmentValue[], ): "date" | "hidden" | "time" { if (isTimeSegment(segment)) return "time"; if (segment.type !== "literal") return "date"; if (segment.text.includes(":")) return "time"; // The space before AM or PM on a 12-hour clock lies between two time // segments, so it belongs to the time. React Aria isolates the time with // bidi marks, which are literals of their own, so the neighbours are the // nearest segments that are not literals. if (/^\s+$/.test(segment.text)) { const index = segments.indexOf(segment); const previous = segments .slice(0, Math.max(index, 0)) .findLast(({ type }) => type !== "literal"); const next = segments .slice(index + 1) .find(({ type }) => type !== "literal"); if (index >= 0 && isTimeSegment(previous) && isTimeSegment(next)) { return "time"; } } if (/^[./\-\s]+$/.test(segment.text)) return "date"; return "hidden"; } // The picker's DateInput keeps its own field state; the segments tell the // picker whether it shows a cleared segment. function DateTimeSegment({ onClearedChange, segment, }: { onClearedChange: (cleared: boolean) => void; segment: DateTimeSegmentValue; }): React.ReactElement { const fieldState = React.useContext(DateFieldStateContext); const cleared = hasClearedSegment(fieldState); React.useLayoutEffect(() => onClearedChange(cleared)); const part = getSegmentPart(segment, fieldState?.segments ?? []); // The segment shows the text the server and the browser agree on: Node's // formatToParts puts a narrow no-break space before AM and PM where the // browser has a plain one (time-field.tsx). return ( {({ text }) => normalizeFormattedText(text)} ); } // The date, the date button, the comma and the time read in the locale's // direction, which React Aria's arrow keys follow, whatever the page's: an // en-US value keeps its date before its time in a right-to-left page. The dir // attribute also isolates the row from the text around it. The field keeps // the page's direction, so the time button ends it at the page's inline end, // and date-time-picker.css keeps the field's insets on the field's sides. function DateTimeSegments({ children, }: { children: React.ReactNode; }): React.ReactElement { const { direction } = useLocale(); return (
{children}
); } function DateTimeHiddenInput({ cleared, controlled, controlledInvalid, controlRef, defaultValue, field, focusTarget, form, required, segmented, state, }: { cleared: boolean; controlled: boolean; controlledInvalid: boolean | undefined; controlRef: React.RefObject; defaultValue: DateTimePickerValue | undefined; field: FieldControlBridge; focusTarget: () => HTMLElement | null; form: string | undefined; required: boolean | undefined; segmented: SegmentedValue; state: DatePickerState; }): React.ReactElement { const inputRef = useFormReset(form, () => { if (!controlled) state.setValue(toCalendarDateTime(defaultValue)); }); // A cleared segment leaves the picker without a value, although React Aria // still holds the last complete one. const fieldValue = cleared ? null : state.value; const serializedValue = formatValue(fieldValue); React.useLayoutEffect(() => { segmented.sync({ cleared, effective: fieldValue, value: state.value }); }); const proxyProps = useFieldControlProxy({ controlled: controlledInvalid !== undefined, disabled: field.disabled, displayValidation: state.displayValidation, focusTarget, form, name: field.name, onRevalidate: state.commitValidation, realtimeValidation: state.realtimeValidation, required, value: serializedValue, }); useFieldErrorSegments(controlRef, { controlled: controlledInvalid !== undefined, ownerInvalid: state.isInvalid, }); return ( <> {proxyProps ? ( ) : null} ); } function TimeSelector({ hourCycle, minuteStep, onClose, open, state, }: { hourCycle: TimeFieldHourCycle; minuteStep: number; onClose: () => void; open: boolean; state: DatePickerState; }): React.ReactElement { const hourOptionLabels = useHourOptionLabels(hourCycle); const resolvedStep = Math.min(Math.max(Math.trunc(minuteStep) || 5, 1), 60); const minuteOptions = React.useMemo( () => Array.from({ length: Math.ceil(60 / resolvedStep) }, (_, index) => String(index * resolvedStep).padStart(2, "0"), ), [resolvedStep], ); const hour = state.timeValue?.hour ?? 0; const minute = state.timeValue?.minute ?? 0; return (
state.setTimeValue(new Time(Number(nextHour), minute)) } open={open} optionLabels={hourOptionLabels} options={hourOptions} slot="time-field-hours" value={String(hour).padStart(2, "0")} /> { state.setTimeValue(new Time(hour, Number(nextMinute))); onClose(); }} open={open} options={minuteOptions} slot="time-field-minutes" value={String(minute).padStart(2, "0")} />
); } export function DateTimePicker({ "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, calendarProps, className, defaultValue, disabled, form, hourCycle = 24, incompleteLabel = "Enter a complete date and time.", locale, minuteStep = 5, name, onValueChange, openDatePickerLabel = "Open date calendar", openTimePickerLabel = "Open time selector", readOnly, required, size, textSize, value, variant = "default", }: DateTimePickerProps): React.ReactElement { const controlled = value !== undefined; const resolvedSize = useResolvedControlSize(size); const resolvedTextSize = textSize ?? (resolvedSize === "lg" || resolvedSize === "xl" ? "base" : undefined); const [timeOpen, setTimeOpen] = React.useState(false); const controlRef = React.useRef(null); const field = useFieldControlBridge({ "aria-describedby": ariaDescribedBy, "aria-labelledby": ariaLabelledBy, disabled, name, }); const fieldAriaLabel = field.labelledBy ? undefined : ariaLabel; // React Aria validates the typed date and time 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 picker's value. const [cleared, setCleared] = React.useState(false); const segmented = useSegmentedValue({ controlled, defaultValue: toCalendarDateTime(defaultValue), incompleteMessage: incompleteLabel, onValueChange: (nextValue) => onValueChange?.(toNativeValue(nextValue)), required, serialize: formatValue, value: toCalendarDateTime(value), }); // React Aria's optional props reject `undefined` under // exactOptionalPropertyTypes, so unset values are omitted. const picker = ( {...(field.describedBy === undefined ? {} : { "aria-describedby": field.describedBy })} {...(fieldAriaLabel === undefined ? {} : { "aria-label": fieldAriaLabel })} {...(field.labelledBy === undefined ? {} : { "aria-labelledby": field.labelledBy })} className="sp-date-time-picker sp-base-date-time-picker contents" data-foundation="date-time-picker" data-slot="date-time-picker" {...(controlled ? { value: segmented.ownerValue as CalendarDateTime | null } : { defaultValue: toCalendarDateTime(defaultValue) })} granularity="minute" hourCycle={hourCycle} isDateUnavailable={(date) => isCalendarDayDisabled(toNativeDate(date), calendarProps?.disabled) } isDisabled={field.disabled} {...(controlledInvalid === undefined ? {} : { isInvalid: controlledInvalid })} {...(readOnly === undefined ? {} : { isReadOnly: readOnly })} {...(required === undefined ? {} : { isRequired: required })} {...dateTimeBounds(calendarProps)} // Typed segments start from the calendar's today at midnight, // `todayDate` when set. {...(calendarProps?.todayDate === undefined ? {} : { placeholderValue: toCalendarDateTimeAt( fromNativeDate(calendarProps.todayDate), ), })} onChange={segmented.onOwnerChange} shouldCloseOnSelect shouldForceLeadingZeros validate={segmented.validate} > {({ isInvalid, state }) => ( <> {(segment) => ( )} setTimeOpen(false)} open={timeOpen} state={state} /> controlRef.current?.querySelector( '[role="spinbutton"], [role="textbox"]', ) ?? null } form={form} required={required} segmented={segmented} state={state} /> )} ); return locale ? ( {picker} ) : ( picker ); } // ~/src/siteplane/components/date-time-picker/date-time-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-time-picker-control { --date-time-picker-control-height: var(--sp-rw-control-md); --date-time-picker-segment-start: calc( var(--sp-rw-space-3) - var(--input-segment-padding-inline) ); max-width: 100%; height: var(--date-time-picker-control-height); overflow: hidden; } .sp-date-time-picker-control[data-size="sm"] { --date-time-picker-control-height: var(--sp-rw-control-sm); --date-time-picker-segment-start: calc( var(--sp-rw-space-2-5) - var(--input-segment-padding-inline) ); } .sp-date-time-picker-control[data-size="lg"] { --date-time-picker-control-height: var(--sp-rw-control-lg); } .sp-date-time-picker-control[data-size="xl"] { --date-time-picker-control-height: var(--sp-rw-control-xl); --date-time-picker-segment-start: calc( var(--sp-rw-space-4) - var(--input-segment-padding-inline) ); } /* The segment row holds the date, the date button, the comma and the time, * in the locale's direction (its dir). The insets belong to the field, * which takes the page's direction: it pads its start, and the time button * ends it at the page's inline end. */ .sp-date-time-picker-control { padding-inline-start: var(--date-time-picker-segment-start); } .sp-date-time-picker-segments { display: flex; flex: none; align-items: center; align-self: stretch; } /* The row orders its parts: the date, its button, the comma and the time. * The time button follows the row in the field. */ .sp-date-time-picker-segment[data-date-time-part="date"] { order: 1; } .sp-date-time-picker-control [data-date-time-part="date-action"] { order: 2; } .sp-date-time-picker-control [data-date-time-part="separator"] { order: 3; } .sp-date-time-picker-segment[data-date-time-part="time"] { order: 4; } .sp-date-time-picker-control [data-date-time-part="time-action"] { order: 5; } .sp-date-time-picker-segment[data-date-time-part="hidden"] { display: none; } /* The surface (border, radius, shadow) is the floating-md recipe that * design-rework.css draws for each popover's data-slot. */ .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover { z-index: 50; outline: none; transform-origin: var(--trigger-anchor-point); } .sp-date-time-picker-calendar-popover { width: max-content; padding: var(--sp-rw-space-4); } /* The time selector is the Time Field's: its width comes from * .sp-time-field-popover, and its padding is the compact one of the Time * Field's Popover, so both selectors look alike. */ .sp-date-time-picker-time-popover { padding: var(--sp-rw-space-2); } :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-entering] { animation: sp-date-time-popover-enter var(--motion-duration-overlay) var(--motion-ease-standard) both; } :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-exiting] { animation: sp-date-time-popover-exit var(--motion-duration-fast) var(--motion-ease-soft-in-out) both; } @keyframes sp-date-time-popover-enter { from { opacity: 0; transform: scale(0.98); } } @keyframes sp-date-time-popover-exit { to { opacity: 0; transform: scale(0.98); } } @media (prefers-reduced-motion: reduce) { :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-entering], :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-exiting] { animation-duration: 1ms; } } html[data-motion-reduce="true"] :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-entering], html[data-motion-reduce="true"] :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-exiting], [data-sp-motion="reduced"] :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-entering], [data-sp-motion="reduced"] :is( .sp-date-time-picker-calendar-popover, .sp-date-time-picker-time-popover )[data-exiting] { animation-duration: 1ms; } } // ~/src/siteplane/components/date-time-picker/index.ts export * from "./date-time-picker";