// // Copyright 2026 DXOS.org // import { CalendarDate, CalendarDateTime, Time, parseDate, parseDateTime, parseTime } from '@internationalized/date'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import React, { type ComponentProps, ReactNode, forwardRef, useCallback, useState } from 'react'; import { DateField, type DateFieldProps, DateInput, DateSegment, TimeField, type TimeFieldProps, } from 'react-aria-components'; import { INPUT_NAME, type InputScopedProps, useInputContext } from '@dxos/react-input'; import { useDensityContext, useElevationContext, useThemeContext } from '../../hooks'; import { type ThemedClassName } from '../../util'; import { DatePicker } from '../DatePicker'; import { Popover } from '../Popover'; import { type InputSharedProps, useInputTrigger } from './Input'; // // Value <-> @internationalized/date adapters. // const tryParse = (parser: (input: string) => T, value: string | undefined): T | null => { if (!value) { return null; } try { return parser(value); } catch { return null; } }; const pad = (value: number, length = 2) => String(value).padStart(length, '0'); const formatCalendarDate = (date: CalendarDate): string => `${pad(date.year, 4)}-${pad(date.month)}-${pad(date.day)}`; const formatCalendarDateTime = (datetime: CalendarDateTime): string => `${pad(datetime.year, 4)}-${pad(datetime.month)}-${pad(datetime.day)}` + `T${pad(datetime.hour)}:${pad(datetime.minute)}`; const formatTime = (time: Time): string => `${pad(time.hour)}:${pad(time.minute)}`; const toCalendarDate = (date: Date) => new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); // // Theming. // // TODO(burdon): Move to theme. const fieldClassNames = 'inline-flex flex-nowrap items-center gap-px whitespace-nowrap tabular-nums focus-within:bg-attention-surface'; // React Aria sets `caret-color: transparent` inline on each segment because spinbuttons replace // the whole value rather than inserting at a caret position. We override with `!important` so a // caret is visible while typing — friendlier UX even though it's slightly misleading mid-segment. // // `tabular-nums` on the field combined with `min-width` per segment keeps each segment a fixed // width regardless of value (so e.g. month `1` and `12` occupy the same space). `text-align: end` // right-aligns the digits within that fixed width. // TODO(burdon): Move to Input.theme.ts const segmentClassNames = 'shrink-0 rounded-xs outline-none text-end [caret-color:currentColor]! ' + 'data-[type=year]:min-w-[4ch] ' + 'data-[type=month]:min-w-[1ch] data-[type=day]:min-w-[1ch] ' + 'data-[type=hour]:min-w-[1ch] data-[type=minute]:min-w-[1ch] ' + 'data-[placeholder]:text-subdued data-[type=literal]:text-subdued ' + 'data-[focused]:bg-accent-bg data-[focused]:text-accent-fg ' + 'data-[invalid]:text-rose-500 ' + 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'; const timeSegmentClassNames = `${segmentClassNames} data-[type=dayPeriod]:text-xs data-[type=dayPeriod]:text-description`; // Match bidi format characters (LRI/RLI/PDI/LRE/RLE/PDF/LRO/RLO) that React Aria inserts to // isolate locale-formatted portions. These are invisible glyphs but the browser still gives // them ~4px of width, which pushes the first visible character of time-only fields out of // alignment with date fields. Keep them in the DOM (so directional isolation survives) but // collapse them to zero width. const BIDI_FORMAT_RE = /^[‪-‮⁦-⁩]+$/; // The segment object react-aria passes to the `DateInput` render function and expects back on // `DateSegment` — derived from the component so no extra dependency on `react-stately` is needed. type DateSegmentData = ComponentProps['segment']; /** * Render a single DateSegment. Locale-specific literals between date and time portions * (e.g. en-US's `", "`) become a plain space; bidi format markers are kept but rendered * zero-width so the visible content lines up at the field's left edge. */ const renderSegment = (segment: DateSegmentData, classNames = segmentClassNames) => { if (segment.type === 'literal') { if (BIDI_FORMAT_RE.test(segment.text)) { // Render as a fixed-width spacer (between date and time portions of a datetime field), // but hide when at the field's edges — opening LRI is the first child of a time-only // field and would push everything right; closing PDI is the last child everywhere. return ; } if (segment.text.includes(',')) { return ( {' '} ); } } return ; }; // // Shared props. // type SegmentedInputBaseProps = InputSharedProps & ThemedClassName<{ 'id'?: string; 'value'?: string; 'defaultValue'?: string; 'onValueChange'?: (value: string) => void; 'disabled'?: boolean; 'autoFocus'?: boolean; 'aria-label'?: string; }>; type SegmentedTimeBearingProps = SegmentedInputBaseProps & { /** `24` (default) renders HH:mm; `12` renders hh:mm with an AM/PM segment. */ hourCycle?: 12 | 24; }; // // Internal helpers. // const useFieldChrome = ({ __inputScope, density: densityProp, elevation: elevationProp, }: { __inputScope?: any; density: InputSharedProps['density']; elevation: InputSharedProps['elevation']; }) => { const { tx } = useThemeContext(); const density = useDensityContext(densityProp); const elevation = useElevationContext(elevationProp); const { id: contextId, validationValence, descriptionId, errorMessageId } = useInputContext(INPUT_NAME, __inputScope); return { tx, density, elevation, validationValence, contextId, descriptionId, errorMessageId }; }; /** * Wraps a field with `Popover.Anchor` and a `DatePicker.Root` whose open state is driven by * the surrounding `Input.Root`'s registered trigger. `Input.TriggerIcon` (a sibling under * `Input.Root`) calls the registered handler on press; the popover anchors to this field. */ const PickerWrapper = ({ children, pickerValue, withTime, disabled = false, calendar, onPickerChange, }: { children: ReactNode; pickerValue: Date | undefined; withTime: boolean; disabled?: boolean; calendar: ReactNode; onPickerChange: (next: Date | undefined) => void; }) => { const [open, setOpen] = useState(false); const openPicker = useCallback(() => { if (!disabled) { setOpen(true); } }, [disabled]); useInputTrigger(disabled ? undefined : openPicker); return ( {children} {calendar} ); }; // // SegmentedDate. // type SegmentedDateProps = SegmentedInputBaseProps; const SegmentedDate = forwardRef>( ( { __inputScope, classNames, density: densityProp, elevation: elevationProp, variant, value, defaultValue, onValueChange, disabled, autoFocus, id, 'aria-label': ariaLabel, }, forwardedRef, ) => { const { tx, density, elevation, validationValence, contextId, descriptionId, errorMessageId } = useFieldChrome({ __inputScope, density: densityProp, elevation: elevationProp, }); const [stringValue, setStringValue] = useControllableState({ prop: value, defaultProp: defaultValue ?? '', onChange: onValueChange, }); const parsed = tryParse(parseDate, stringValue); const fieldProps: DateFieldProps = { 'value': parsed, 'onChange': (next) => setStringValue(next ? formatCalendarDate(next) : ''), 'isDisabled': disabled, autoFocus, 'aria-label': ariaLabel ?? 'date', 'granularity': 'day', 'shouldForceLeadingZeros': true, }; const field = ( {renderSegment} ); return ( setStringValue(next ? formatCalendarDate(toCalendarDate(next)) : '')} withTime={false} disabled={disabled} calendar={} > {field} ); }, ); SegmentedDate.displayName = 'Input.SegmentedDate'; // // SegmentedTime — no picker (no time-only picker primitive available); does not register a trigger. // type SegmentedTimeProps = SegmentedTimeBearingProps; const SegmentedTime = forwardRef>( ( { __inputScope, classNames, density: densityProp, elevation: elevationProp, variant, value, defaultValue, onValueChange, disabled, autoFocus, id, hourCycle = 24, 'aria-label': ariaLabel, }, forwardedRef, ) => { const { tx, density, elevation, validationValence, contextId, descriptionId, errorMessageId } = useFieldChrome({ __inputScope, density: densityProp, elevation: elevationProp, }); const [stringValue, setStringValue] = useControllableState({ prop: value, defaultProp: defaultValue ?? '', onChange: onValueChange, }); const parsed = tryParse(parseTime, stringValue); const fieldProps: TimeFieldProps