// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file DateTimeInput.tsx * @input Uses React, Field, Calendar, NativeDateSegment, NativeTimeSegment, TouchDateTimeField, usePopover, useAnnounce, time parsing utilities, StyleX intrinsic flex layout * @output Exports DateTimeInput component, DateTimeInputProps, and native picker type * @position Core implementation; consumed by index.ts, tested by DateTimeInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DateTimeInput/NativeDateSegment.tsx (native date surface) * - /packages/core/src/DateTimeInput/NativeTimeSegment.tsx (native time surface) * - /packages/core/src/DateTimeInput/NativePickerSegments.test.tsx (native surface tests) * - /packages/core/src/DateTimeInput/DateTimeInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/DateTimeInput/DateTimeInput.test.tsx (desktop tests for new/changed behavior) * - /packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx (touch-surface tests) * - /packages/core/src/DateTimeInput/index.ts (exports if types change) * - /apps/storybook/stories/DateTimeInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/DateTimeInput/ (showcase blocks) */ import { useId, useState, useCallback, useEffect, useRef, useMemo, useOptimistic, useTransition, type FocusEvent, type KeyboardEvent } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, radiusVars, spacingVars, typographyVars, typeScaleVars, fontWeightVars } from "../theme/tokens.stylex.js"; import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles } from "../Field/index.js"; import { Icon } from "../Icon/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { Spinner } from "../Spinner/index.js"; import { Calendar, type ISODateString, type CalendarHandle, type DayOfWeek, type DayOfWeekName } from "../Calendar/index.js"; import { useCalendarConstraints } from "../Calendar/hooks/index.js"; import { usePopover } from "../Popover/index.js"; import { useTooltip } from "../Tooltip/index.js"; import { useInputContainer } from "../hooks/useInputContainer.js"; import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js"; import { type ISOTimeString, parseDateInput, parseISOTime, parseTimeInput, formatDisplayTime12h, formatDisplayTime24h, formatISOTime, isTimeInRange, adjustTime, isImeKeyEvent, mergeProps, isFocusDetached } from "../utils/index.js"; import { plainDateFromISO, plainDateToISO, plainDateFormat, DATE_FORMAT_LONG } from "../utils/plainDate.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useMediaQuery } from "../hooks/useMediaQuery.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { useLocale, useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { useHighlightedOptionScroll } from "../hooks/useHighlightedOptionScroll.js"; import { NativeDateSegment } from "./NativeDateSegment.js"; import { NativeTimeSegment } from "./NativeTimeSegment.js"; import { TouchDateTimeField } from "./TouchDateTimeField.js"; export type ISODateTimeString = string & { readonly __brand: 'ISODateTimeString'; }; export type DateTimeInputNativePicker = 'touch' | 'always' | 'never'; export type DateTimeInputHourFormat = '12h' | '24h'; export type DateTimeInputSize = 'sm' | 'md' | 'lg'; /** Supported minute increments for arrow-key stepping of the time field. */ export type DateTimeInputTimeIncrement = 1 | 5 | 10 | 15 | 30; /** * Supported minute cadences for the preset-time dropdown. Every value divides * an hour evenly, so each option lands on a round clock time. */ export type DateTimeInputTimeOptionInterval = 5 | 10 | 15 | 30 | 60; export type { InputStatus as DateTimeInputStatus, InputStatusType as DateTimeInputStatusType } from "../Field/index.js"; // Two 196px segments plus the 8px gap fit at exactly 400px. Below that, // flex wrapping moves each growing segment onto its own full-width row. const HORIZONTAL_SEGMENT_BASIS = 196; const styles = { row: { k1xSpc: "x78zum5", kwnvtZ: "x1a02dak", kOIVth: "x1txdalj", $$css: true as const }, nativeRow: { k7Eaqz: "xfb3i0g", $$css: true as const }, iconButton: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", $$css: true as const }, iconButtonDisabled: { kkrTdU: "xt0e3qv", $$css: true as const }, dateWrapper: { kUk6DE: "x98rzlu", kCS8Yb: "xrjwirl", k7Eaqz: "xeuugli", $$css: true as const }, timeWrapper: { kUk6DE: "x98rzlu", kCS8Yb: "xrjwirl", k7Eaqz: "xeuugli", $$css: true as const }, timeOption: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kzqmXN: "xh8yej3", k8WAf4: "x1vofgu7", kg3NbH: "xf314gf", kaIpWk: "xh6dtrn", kkrTdU: "x1ypdohk x16khyan", k9WMMc: "x1yc453h", kMv6JI: "x9ynric", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", kMwMTN: "x1tgivj0", kWkggS: "xjbqb8w", $$css: true as const }, timeOptionHighlighted: { kWkggS: "x1lmrjuc", $$css: true as const }, timeOptionSelected: { k63SB2: "x1e4wzip", $$css: true as const } }; /** * Size-specific padding for the preset-time options, so an `sm` field gets a * compact list. Matches DropdownMenuItem / Selector / BaseTypeahead. */ const timeOptionSizeStyles = { sm: { k8WAf4: "xu0wf1k", kg3NbH: "xf314gf", $$css: true as const }, md: { k8WAf4: "x1vofgu7", $$css: true as const }, lg: { k8WAf4: "xce4md1", $$css: true as const } }; const TOUCH_POINTER_QUERY = '(pointer: coarse)'; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; export interface DateTimeInputProps extends Omit { /** Ref forwarded to the date input element */ ref?: React.Ref; /** * Label text for the input (required for accessibility). */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Description text displayed between the label and input. */ description?: string; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the field is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * Whether the input is disabled. * @default false */ isDisabled?: boolean; /** * Explains why the input is disabled. When set together with * `isDisabled`, the input shows a tooltip with this text on hover and * keyboard focus, and the date and time fields stay focusable (via * `aria-disabled`) so the reason is discoverable by keyboard and assistive * technology. Typing and calendar activation stay blocked. * * Use this instead of wrapping a disabled input in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * The selected datetime in ISO 8601 format ("YYYY-MM-DDTHH:MM" or "YYYY-MM-DDTHH:MM:SS"). */ value?: ISODateTimeString; /** * Callback fired when the datetime changes. * Called with undefined when input is cleared. */ onChange: (value: ISODateTimeString | undefined) => void; /** * Async action on change. Fires after onChange. */ changeAction?: (value: ISODateTimeString | undefined) => void | Promise; /** * Whether the input is in a loading state. * @default false */ isLoading?: boolean; /** * Minimum selectable datetime in ISO format. * Constrains both date and time selection. */ min?: ISODateTimeString; /** * Maximum selectable datetime in ISO format. * Constrains both date and time selection. */ max?: ISODateTimeString; /** * Custom date constraint functions. * Date is disabled in the calendar if ANY function returns false. */ dateConstraints?: ReadonlyArray<(date: Date) => boolean>; /** * Whether to include seconds in the time portion. * @default false */ hasSeconds?: boolean; /** * Hour display format. * @default '12h' */ hourFormat?: DateTimeInputHourFormat; /** * Minute step for the time field. Astryx's typed field uses it for arrow-key * stepping. The default value permits the native time picker; a non-default * value retains the Astryx time field because iOS does not apply native * `step` as picker cadence. Ignored by the Astryx touch sheet. * @default 1 */ timeIncrement?: DateTimeInputTimeIncrement; /** * Minute cadence for a dropdown of preset times on the desktop time field. Set * it to turn the time field into a combobox listing every valid time at that * cadence (`60` gives the 12 AM - 11 PM list, `15` a quarter-hour list). * * Omitted, the desktop time field stays a plain text input: typed entry and * arrow-key stepping only, with no combobox semantics added to the * accessibility tree. Typed entry keeps working when the dropdown is on — the * list is a shortcut, not a restriction, so a time between two options can * still be typed. * * Setting this prop retains Astryx's typed/combobox time field even when * `nativePicker` otherwise selects native controls, because the OS picker has * no equivalent preset list. The Astryx touch sheet uses wheels instead. * Independent of `timeIncrement`, which governs arrow-key stepping. */ timeOptionInterval?: DateTimeInputTimeOptionInterval; /** * Whether to show a clear button when a value is set. * @default false */ hasClear?: boolean; /** * Placeholder text shown in the date portion when no date is selected. * @default "Select a date" */ placeholder?: string; /** * Placeholder text shown in the time portion when no time is selected. * On touch, this appears in the closed time segment before a time is chosen. * @default "Select a time" */ timePlaceholder?: string; /** * Accessible label for the time portion of the field. Defaults to * `"{label} time"` so it is tied to the field's own label and localizable, * rather than a hardcoded English "Time". */ timeLabel?: string; /** * The size of the inputs. * @default 'md' */ size?: DateTimeInputSize; /** * Status indicator for the input. */ status?: InputStatus; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; /** * Number of months to display in the desktop calendar popover. Ignored on the * mobile touch sheet, whose Date panel always shows one swipe-paged month at a * time. * @default 1 */ numberOfMonths?: 1 | 2; /** * First day of week in the calendar. Accepts a number * (0 = Sunday … 6 = Saturday) or a three-letter day name ('sun'–'sat', * case-insensitive). * @default 0 */ weekStartsOn?: DayOfWeek | DayOfWeekName; /** * When date and time picking are handed to the browser/OS instead of Astryx's * calendar and time surfaces. * * - `'touch'` (default): native date and time inputs on a coarse primary * pointer; Astryx's typed fields and popovers otherwise * - `'always'`: native date and time inputs wherever the browser supports them * - `'never'`: Astryx's own surfaces everywhere — typed fields on a fine * pointer and the coordinated Date/Time bottom sheet on a coarse pointer * * Native pickers cannot express `numberOfMonths`, `weekStartsOn`, * `dateConstraints`, or `timeOptionInterval`. Calendar-only options are * ignored in native mode and constraints are enforced on commit. The native * time control is used for the default minute-precision contract; requesting * `hasSeconds`, a non-default `timeIncrement`, or `timeOptionInterval` keeps * Astryx's time field because iOS cannot represent those behaviors faithfully. * `min` / `max` are forwarded to each native control as hints and enforced in * JavaScript. `hourFormat` formats the closed value; the OS picker follows the * user's locale. * * @default 'touch' * @example * ``` * * ``` */ nativePicker?: DateTimeInputNativePicker; } function splitDateTime(dt: ISODateTimeString | undefined): { date: ISODateString | undefined; time: ISOTimeString | undefined; } { if (!dt) { return { date: undefined, time: undefined }; } const tIndex = dt.indexOf('T'); if (tIndex === -1) { return { date: dt as unknown as ISODateString, time: undefined }; } return { date: dt.slice(0, tIndex) as ISODateString, time: dt.slice(tIndex + 1) as ISOTimeString }; } function combineDateTime(date: ISODateString | undefined, time: ISOTimeString | undefined): ISODateTimeString | undefined { if (!date || !time) { return undefined; } return `${date}T${time}` as ISODateTimeString; } const MINUTES_PER_DAY = 24 * 60; function getDefaultTime(hasSeconds: boolean): ISOTimeString { const now = new Date(); return formatISOTime({ hour: now.getHours(), minute: now.getMinutes(), second: now.getSeconds() }, hasSeconds); } /** * A combined date and time picker with side-by-side date input and * time input under a single label. The date input opens a calendar * popover; the time input supports typed entry and arrow-key adjustment. * * @example * ``` * * ``` */ function PointerDateTimeField({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, disabledMessage, value, onChange, changeAction, isLoading = false, min, max, dateConstraints, hasSeconds = false, hourFormat = '12h', timeIncrement = 1, timeOptionInterval, hasClear = false, placeholder: placeholderFromProps, timePlaceholder: timePlaceholderFromProps, timeLabel, size: sizeProp, status, labelTooltip, numberOfMonths = 1, weekStartsOn, nativePicker = 'touch', width, xstyle, className, style, ref, ...rest }: DateTimeInputProps) { const t = useTranslator(); const locale = useLocale(); const isTouch = useMediaQuery(TOUCH_POINTER_QUERY); const usesNativePicker = nativePicker === 'always' || nativePicker === 'touch' && isTouch; // iOS's native time picker has no seconds wheel, treats step as validation // rather than picker cadence, and cannot express our preset-time list. Keep // the Astryx time field for those explicit contracts instead of losing them. const usesNativeTimePicker = usesNativePicker && !hasSeconds && timeIncrement === 1 && timeOptionInterval === undefined; const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); // Speaks arrow-key stepping results through the persistent live regions: // stepping programmatically rewrites a plain textbox's value, which screen // readers do not announce on their own (WCAG 4.1.2). const announce = useAnnounce(); const placeholder = placeholderFromProps ?? t('@astryx.dateTimeInput.placeholder'); const timePlaceholder = timePlaceholderFromProps ?? t('@astryx.dateTimeInput.timePlaceholder'); const size = useSize(sizeProp, 'md'); const dateInputId = useId(); const timeInputId = useId(); const descriptionID = useId(); const statusMessageID = useId(); const dateInputRef = useRef(null); const mergedDateInputRef = useMergedRefs(ref, dateInputRef); const timeInputRef = useRef(null); const timeContainerRef = useRef(null); const calendarRef = useRef(null); const lastFiredDateRef = useRef(undefined); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isPendingChange = optimisticValue !== value; const isBusy = isLoading || isPendingChange; // A native wheel emits several edits while it remains open. Disabling the // control after the first optimistic edit detaches the iOS picker, so pending // changeAction work stays interactive just like TouchDateTimeField. Explicit // loading still disables every surface. const isEffectivelyDisabled = isDisabled || isLoading || !usesNativePicker && isPendingChange; // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the outer row container (which already exists) // and both the date and time inputs stay perceivable via aria-disabled // instead of the disabled attribute. Typing is blocked with readOnly and the // value mutation guards; calendar activation is blocked by the // isEffectivelyDisabled guards. Only the persistent isDisabled state (not the // transient busy state) surfaces a reason. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container div is not naturally focusable; focusin bubbles up from // the inputs, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // DateTimeInput fixes its status presentation to the detached message box, // so the shared helper suppresses the on-field icon (the message box carries // its own leading glyph). Routed through the helper for consistency with the // rest of the bordered input family. const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant: 'detached' }); const ariaDescribedBy = [description ? descriptionID : null, status?.message ? statusMessageID : null, statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null].filter(Boolean).join(' ') || undefined; // Split min/max and current value const minParts = useMemo(() => splitDateTime(min), [min]); const maxParts = useMemo(() => splitDateTime(max), [max]); const valueParts = useMemo(() => splitDateTime(optimisticValue), [optimisticValue]); // Date constraints from min/max const calendarMin = minParts.date; const calendarMax = maxParts.date; const { isDateDisabled } = useCalendarConstraints({ min: calendarMin, max: calendarMax, dateConstraints }); // Time constraints change depending on selected date const timeMin = useMemo(() => { if (!minParts.date || !minParts.time || !valueParts.date) { return undefined; } return valueParts.date === minParts.date ? minParts.time : undefined; }, [minParts.date, minParts.time, valueParts.date]); const timeMax = useMemo(() => { if (!maxParts.date || !maxParts.time || !valueParts.date) { return undefined; } return valueParts.date === maxParts.date ? maxParts.time : undefined; }, [maxParts.date, maxParts.time, valueParts.date]); // --- Date input state --- const [datePendingInput, setDatePendingInput] = useState(null); const prevDateRef = useRef(valueParts.date); if (valueParts.date !== prevDateRef.current) { prevDateRef.current = valueParts.date; if (valueParts.date !== lastFiredDateRef.current) { lastFiredDateRef.current = undefined; if (datePendingInput !== null) { setDatePendingInput(null); } } } const dateDisplayValue = datePendingInput !== null ? datePendingInput : valueParts.date && /^\d{4}-\d{2}-\d{2}$/.test(valueParts.date) ? plainDateFormat(plainDateFromISO(valueParts.date), DATE_FORMAT_LONG, locale) : ''; const isDateInputValid = datePendingInput === null || !datePendingInput.trim() ? true : parseDateInput(datePendingInput, locale) !== null; // --- Time input state --- const [timePendingInput, setTimePendingInput] = useState(null); const [isTimeFocused, setIsTimeFocused] = useState(false); // Native date and time are separate OS pickers. Hold a time chosen before the // date exists, then combine it when the date picker commits. const [nativeTimeDraft, setNativeTimeDraft] = useState(undefined); const previousNativeValueRef = useRef(optimisticValue); if (optimisticValue !== previousNativeValueRef.current) { previousNativeValueRef.current = optimisticValue; if (nativeTimeDraft !== undefined) { setNativeTimeDraft(undefined); } } const nativeTimeValue = nativeTimeDraft ?? valueParts.time; const formatDisplayTime = hourFormat === '12h' ? formatDisplayTime12h : formatDisplayTime24h; const timeDisplayValue = useMemo(() => { if (timePendingInput !== null) { return timePendingInput; } return valueParts.time ? formatDisplayTime(valueParts.time, hasSeconds) : ''; }, [timePendingInput, valueParts.time, formatDisplayTime, hasSeconds]); const isTimeInputValid = useMemo(() => { if (timePendingInput === null || !timePendingInput.trim()) { return true; } const parsed = parseTimeInput(timePendingInput, hasSeconds); if (!parsed) { return false; } return isTimeInRange(parsed, timeMin, timeMax); }, [timePendingInput, hasSeconds, timeMin, timeMax]); const resolvedTimePlaceholder = useMemo(() => { if (isTimeFocused && !timeDisplayValue) { return hourFormat === '12h' ? t('@astryx.dateTimeInput.timeHint12h') : t('@astryx.dateTimeInput.timeHint24h'); } return timePlaceholder; }, [isTimeFocused, timeDisplayValue, hourFormat, timePlaceholder, t]); // The time field's own accessible name. The option list is named from this // rather than from `label`, so a consumer-supplied timeLabel renames both // together instead of leaving the list announcing the old name. const resolvedTimeLabel = timeLabel ?? t('@astryx.dateTimeInput.timeSuffix', { label }); // --- Preset time options (#2727) --- // Opt-in: without timeOptionInterval the time field keeps exactly the // semantics it shipped with — a plain text input, no combobox role, no // second listbox in the accessibility tree. const hasTimeOptions = !usesNativeTimePicker && timeOptionInterval !== undefined; const timeOptions = useMemo(() => { if (!hasTimeOptions || timeOptionInterval === undefined) { return []; } const options: { time: ISOTimeString; label: string; }[] = []; for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += timeOptionInterval) { const time = formatISOTime({ hour: Math.floor(minutes / 60), minute: minutes % 60, second: 0 }, hasSeconds); // The same bound the typed path enforces, so the list can never offer a // time that typing the identical string would reject. if (!isTimeInRange(time, timeMin, timeMax)) { continue; } options.push({ time, label: formatDisplayTime(time, hasSeconds) }); } return options; }, [hasTimeOptions, timeOptionInterval, hasSeconds, timeMin, timeMax, formatDisplayTime]); // --- Unified change handler --- const fireChange = useCallback((newValue: ISODateTimeString | undefined) => { if (isEffectivelyDisabled) { return; } onChange(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [isEffectivelyDisabled, onChange, changeAction, startTransition, setOptimisticValue]); // --- Popover --- const popover = usePopover({ dialogLabel: t('@astryx.dateTimeInput.dialogLabel'), closeButtonLabel: t('@astryx.dateInput.closeCalendar'), // Return focus to the date input when the calendar closes — but only when // the dismiss left focus detached (Escape, or a click on non-focusable // empty space), which the focus trap can't restore on its own. A native // popover="auto" light-dismiss fires synchronously with the pointer event // that moved focus, so if the user clicked another control — the time // input, the clear button, another field, anywhere — focus has already // landed there; reclaiming it would fight their click. onHide: () => { if (isFocusDetached()) { dateInputRef.current?.focus(); } } }); const handleCalendarToggle = useCallback(() => { if (!isEffectivelyDisabled) { if (popover.isOpen) { popover.hide(); } else { popover.show(); } } }, [isEffectivelyDisabled, popover]); const handleDateInputClick = useCallback(() => { if (!isEffectivelyDisabled && !popover.isOpen) { popover.show({ skipAutoFocus: true }); } }, [isEffectivelyDisabled, popover]); // --- Date handlers --- const handleDateChange = useCallback((newDate: ISODateString, source: 'calendar' | 'input') => { const currentTime = valueParts.time ?? nativeTimeDraft ?? getDefaultTime(hasSeconds); let effectiveTime = currentTime; if (minParts.date && newDate === minParts.date && minParts.time) { if (!isTimeInRange(effectiveTime, minParts.time, undefined)) { effectiveTime = minParts.time; } } if (maxParts.date && newDate === maxParts.date && maxParts.time) { if (!isTimeInRange(effectiveTime, undefined, maxParts.time)) { effectiveTime = maxParts.time; } } const combined = combineDateTime(newDate, effectiveTime); if (combined) { fireChange(combined); } if (source === 'calendar') { setDatePendingInput(null); popover.hide(); } }, [valueParts.time, nativeTimeDraft, hasSeconds, minParts, maxParts, fireChange, popover]); const handleNativeDateChange = useCallback((newDate: ISODateString | undefined) => { if (newDate === undefined) { fireChange(undefined); return; } handleDateChange(newDate, 'input'); }, [fireChange, handleDateChange]); const handleNativeTimeChange = useCallback((newTime: ISOTimeString | undefined) => { if (newTime === undefined) { setNativeTimeDraft(undefined); if (optimisticValue !== undefined) { fireChange(undefined); } return; } if (valueParts.date) { const combined = combineDateTime(valueParts.date, newTime); if (combined) { fireChange(combined); } return; } // A datetime cannot be emitted until its date exists, but the OS time // picker is independently reachable. Preserve this draft for the date // commit instead of making the user's first choice disappear. setNativeTimeDraft(newTime); }, [fireChange, optimisticValue, valueParts.date]); const handleDateInputChange = useCallback((e: React.ChangeEvent) => { // With a disabledMessage the input drops `disabled` for focusability, so // guard value mutation explicitly (readOnly also blocks typing). if (isEffectivelyDisabled) { return; } const text = e.target.value; setDatePendingInput(text); const parsed = parseDateInput(text, locale); if (parsed && plainDateToISO(parsed) !== valueParts.date && !isDateDisabled(parsed)) { const parsedISO = plainDateToISO(parsed); lastFiredDateRef.current = parsedISO; handleDateChange(parsedISO, 'input'); calendarRef.current?.navigateTo(parsedISO); } }, [valueParts.date, isDateDisabled, handleDateChange, isEffectivelyDisabled, locale]); const commitDatePendingInput = useCallback(() => { if (datePendingInput === null) { return; } if (!datePendingInput.trim()) { if (value !== undefined) { fireChange(undefined); } setDatePendingInput(null); return; } const parsed = parseDateInput(datePendingInput, locale); if (parsed && !isDateDisabled(parsed)) { const parsedISO = plainDateToISO(parsed); if (parsedISO !== valueParts.date) { handleDateChange(parsedISO, 'input'); } } setDatePendingInput(null); }, [datePendingInput, value, valueParts.date, fireChange, isDateDisabled, handleDateChange, locale]); const handleDateBlur = useCallback(() => { commitDatePendingInput(); }, [commitDatePendingInput]); const handleDateKeyDown = useCallback((e: React.KeyboardEvent) => { // Guard the composing keydown (fires before compositionend): an IME uses // Enter to commit the candidate and Escape to cancel it, so without this // a CJK user committing a syllable with Enter would commit the pending // date instead. See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } if (e.key === 'Escape' && popover.isOpen) { e.preventDefault(); popover.hide(); } else if ((e.key === 'ArrowDown' || e.altKey && e.key === 'ArrowDown') && !popover.isOpen) { // APG combobox: ArrowDown (and Alt+ArrowDown) opens the calendar // popover from the keyboard, keeping focus in the input (forms-13). e.preventDefault(); if (!isEffectivelyDisabled) { popover.show({ skipAutoFocus: true }); } } else if (e.key === 'Enter') { e.preventDefault(); commitDatePendingInput(); } }, [popover, commitDatePendingInput, isEffectivelyDisabled]); // --- Time-option popover (#2727) --- const timeListboxId = useId(); const [highlightedTimeIndex, setHighlightedTimeIndex] = useState(-1); // With popover="auto", showing the popover between pointerdown and // pointerup/click lets the browser's light-dismiss treat that same click as // "outside" and close it again. Defer the show past the click, as // BaseTypeahead does for the same reason. const timePointerActiveRef = useRef(false); // Whether the highlight is still the one typing derived, or the user has // since moved it themselves. Enter honours typed text only in the former // case — otherwise the field would commit something other than the option // it is showing as active. const timeHighlightFollowsTypingRef = useRef(true); const markTimePointerActive = useCallback(() => { timePointerActiveRef.current = true; document.addEventListener('click', () => { timePointerActiveRef.current = false; }, { once: true }); }, []); const timePopover = usePopover({ hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, // The popup's own role="listbox" carries the semantics; the input keeps // DOM focus, so announcing a dialog here would misrepresent it. role: 'none', onHide: () => setHighlightedTimeIndex(-1) }); // Anchor the list to the time wrapper, not the date input the field's own // trigger ref points at. useEffect(() => { if (!hasTimeOptions) { return; } const el = timeContainerRef.current; if (el) { timePopover.triggerRef(el); } return () => { timePopover.triggerRef(null); }; }, [timePopover, hasTimeOptions]); const timeOptionId = useCallback((index: number) => `${timeListboxId}-option-${index}`, [timeListboxId]); // A field can go disabled or busy while its list is open — a changeAction // starting elsewhere in the form is enough. Leave it up and the dropdown // hangs over a control that no longer accepts input. // The same applies to a list that empties after it opened, which a narrowing // min/max window does. An open-but-empty list gates the keyboard switch off, // so the arrows quietly fall back to stepping the value instead. useEffect(() => { if (timePopover.isOpen && (isEffectivelyDisabled || timeOptions.length === 0)) { timePopover.hide(); } }, [isEffectivelyDisabled, timeOptions.length, timePopover]); // The option list can shrink under an open popover — min/max tighten when // the date moves onto a boundary day, or the cadence prop changes. A stale // index past the new end shows no highlight at all and makes the arrow keys // look dead until the user walks it back into range. // Clamped as it is read rather than synced through an effect: no extra // render, and the index can never point past the end even for the paint // where the list shrank. const activeTimeIndex = highlightedTimeIndex > timeOptions.length - 1 ? timeOptions.length - 1 : highlightedTimeIndex; // Keep the active option visible; hover highlights never scroll (#6077). // Both sides live in useHighlightedOptionScroll. const highlightTimeOnHover = useHighlightedOptionScroll({ isOpen: timePopover.isOpen, highlightedIndex: activeTimeIndex, setHighlightedIndex: setHighlightedTimeIndex, getOptionId: timeOptionId, itemCount: timeOptions.length }); /** * The selected time in the same shape the options carry. splitDateTime slices * whatever follows the `T`, so a caller passing `14:00:00` with hasSeconds * off leaves `"14:00:00"` against options of `"14:00"` — comparing raw would * mark nothing selected, silently. */ const selectedOptionTime = useMemo(() => { if (!valueParts.time) { return undefined; } const parsed = parseISOTime(valueParts.time); return parsed ? formatISOTime(parsed, hasSeconds) : undefined; }, [valueParts.time, hasSeconds]); /** * Index of the option at or immediately before `time`, so opening on a value * that is not itself an option (13:07 in a 15-minute list) still lands the * highlight somewhere sensible instead of nowhere. */ const closestOptionIndex = useCallback((time: ISOTimeString | undefined) => { if (timeOptions.length === 0) { return -1; } // No time yet — the state a field lands in once a date is picked. The // list still opens on the first option, per the APG listbox pattern; // leaving it inactive made ArrowUp and Enter do nothing at all. if (!time) { return 0; } let candidate = -1; for (let i = 0; i < timeOptions.length; i++) { if (timeOptions[i].time <= time) { candidate = i; } else { break; } } // A value below every option (min pushes the list past it) still gets the // first option rather than an empty highlight. return candidate === -1 ? 0 : candidate; }, [timeOptions]); const showTimeOptions = useCallback(() => { if (!hasTimeOptions || isEffectivelyDisabled || timePopover.isOpen || // A min/max window narrower than the cadence survives no option at all. // Opening on that would show an empty panel with nothing to pick. timeOptions.length === 0) { return; } setHighlightedTimeIndex(closestOptionIndex(selectedOptionTime)); // useInputContainer routes wrapper clicks through input.click() once the // input advertises a popup, and a programmatic click does not focus. The // input must hold DOM focus or aria-activedescendant announces nothing. timeInputRef.current?.focus(); if (timePointerActiveRef.current) { document.addEventListener('click', () => requestAnimationFrame(() => timePopover.show({ skipAutoFocus: true })), { once: true }); } else { timePopover.show({ skipAutoFocus: true }); } }, [hasTimeOptions, isEffectivelyDisabled, timePopover, timeOptions.length, closestOptionIndex, selectedOptionTime]); /** * The single commit path for a time chosen from the list. Deliberately the * same shape as the typed path: same range check, same date requirement, so * picking 9:00 AM and typing "9:00 AM" are indistinguishable downstream. */ const commitTimeOption = useCallback((time: ISOTimeString) => { if (isEffectivelyDisabled || !isTimeInRange(time, timeMin, timeMax)) { return; } setTimePendingInput(null); // Compared against the normalised time, the same one the selected marker // uses. Comparing the raw value instead made clicking the option that // renders as selected emit a change, whenever the value carried seconds // the field does not display. if (time !== selectedOptionTime && valueParts.date) { const combined = combineDateTime(valueParts.date, time); if (combined) { fireChange(combined); } } timePopover.hide(); timeInputRef.current?.focus(); }, [isEffectivelyDisabled, timeMin, timeMax, selectedOptionTime, valueParts.date, fireChange, timePopover]); // --- Time handlers --- const handleTimeInputChange = useCallback((e: React.ChangeEvent) => { // With a disabledMessage the input drops `disabled` for focusability, so // guard value mutation explicitly (readOnly also blocks typing). if (isEffectivelyDisabled) { return; } const text = e.target.value; setTimePendingInput(text); const parsed = parseTimeInput(text, hasSeconds); // Typing narrows nothing — free-form entry is the contract, and a time // between two options must stay reachable. The list instead follows the // typed value so Enter lands somewhere the user expects. if (timePopover.isOpen && parsed) { timeHighlightFollowsTypingRef.current = true; setHighlightedTimeIndex(closestOptionIndex(parsed)); } if (parsed && isTimeInRange(parsed, timeMin, timeMax) && parsed !== valueParts.time) { if (valueParts.date) { const combined = combineDateTime(valueParts.date, parsed); if (combined) { fireChange(combined); } } } }, [hasSeconds, timeMin, timeMax, valueParts.time, valueParts.date, fireChange, isEffectivelyDisabled, timePopover.isOpen, closestOptionIndex]); const handleTimeFocus = useCallback(() => { // A disabled/busy input stays focusable (via aria-disabled) so its reason // is discoverable, but it must not present editing affordances — keep the // static placeholder rather than swapping in the format hint. if (isEffectivelyDisabled) { return; } setIsTimeFocused(true); }, [isEffectivelyDisabled]); const handleTimeBlur = useCallback((e: FocusEvent) => { setIsTimeFocused(false); // Native light dismiss only fires on outside clicks and Escape, so a focus // move that is neither — Tab handled elsewhere, or a programmatic focus — // would strand the listbox open in the top layer. Focus landing inside the // field or the popup itself is not a leave. Mirrors BaseTypeahead. if (timePopover.isOpen) { const next = e.relatedTarget as Node | null; const popoverEl = next ? document.getElementById(timePopover.id) : null; if (!next || !(timeContainerRef.current?.contains(next) || popoverEl?.contains(next))) { timePopover.hide(); } } if (timePendingInput === null) { return; } if (!timePendingInput.trim()) { // Empty time: revert display to previous value (don't emit partial datetime) setTimePendingInput(null); return; } const parsed = parseTimeInput(timePendingInput, hasSeconds); if (parsed && isTimeInRange(parsed, timeMin, timeMax)) { if (parsed !== valueParts.time && valueParts.date) { const combined = combineDateTime(valueParts.date, parsed); if (combined) { fireChange(combined); } } } setTimePendingInput(null); }, [timePendingInput, hasSeconds, timeMin, timeMax, valueParts, fireChange, timePopover]); const handleTimeKeyDown = useCallback((e: KeyboardEvent) => { // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate // window uses those same arrows to navigate candidates, so guard the // composing keydown (fires before compositionend) to avoid stealing them // mid-composition. See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } // The dropdown claims the arrow keys only while it is open. Closed, they // keep stepping the value by timeIncrement exactly as they always have — // that behaviour is documented and tested, so the list borrows Alt+Arrow // Down (the APG "open without moving" binding) to open instead. if (hasTimeOptions && !isEffectivelyDisabled) { if (e.key === 'Escape' && timePopover.isOpen) { e.preventDefault(); timePopover.hide(); return; } if (e.key === 'ArrowDown' && e.altKey) { e.preventDefault(); // A keyboard open is never mid-click, so clear the deferral flag. A // press that ended without a click would otherwise leave it set and // hold every later open hostage to the next click anywhere. timePointerActiveRef.current = false; showTimeOptions(); return; } if (timePopover.isOpen && timeOptions.length > 0) { switch (e.key) { case 'ArrowDown': e.preventDefault(); timeHighlightFollowsTypingRef.current = false; setHighlightedTimeIndex(activeTimeIndex < timeOptions.length - 1 ? activeTimeIndex + 1 : activeTimeIndex); return; case 'ArrowUp': e.preventDefault(); timeHighlightFollowsTypingRef.current = false; setHighlightedTimeIndex(activeTimeIndex > 0 ? activeTimeIndex - 1 : activeTimeIndex); return; case 'Home': e.preventDefault(); timeHighlightFollowsTypingRef.current = false; setHighlightedTimeIndex(0); return; case 'End': e.preventDefault(); timeHighlightFollowsTypingRef.current = false; setHighlightedTimeIndex(timeOptions.length - 1); return; case 'Enter': { e.preventDefault(); // Typed text wins over the highlight. The highlight only tracks // the option at or before what was typed, so committing it here // would round 1:07 PM down to 1:00 PM and discard the entry. const typed = timeHighlightFollowsTypingRef.current && timePendingInput !== null ? parseTimeInput(timePendingInput, hasSeconds) : null; const option = timeOptions[activeTimeIndex]; if (typed) { commitTimeOption(typed); } else if (option) { commitTimeOption(option.time); } return; } case 'Tab': // Let focus leave; blur commits any typed text as usual. timePopover.hide(); return; default: break; } } } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); let currentTime = valueParts.time; if (!currentTime) { const now = new Date(); currentTime = formatISOTime({ hour: now.getHours(), minute: now.getMinutes(), second: now.getSeconds() }, hasSeconds); } const delta = e.key === 'ArrowUp' ? timeIncrement : -timeIncrement; const newTime = adjustTime(currentTime, delta, hasSeconds); if (isTimeInRange(newTime, timeMin, timeMax) && valueParts.date) { const combined = combineDateTime(valueParts.date, newTime); if (combined) { fireChange(combined); // Screen readers do not announce the programmatic value rewrite, // so speak the new time explicitly (WCAG 4.1.2). announce(formatDisplayTime(newTime, hasSeconds)); } } } }, [valueParts, hasSeconds, timeIncrement, timeMin, timeMax, fireChange, announce, formatDisplayTime, hasTimeOptions, isEffectivelyDisabled, timePopover, timeOptions, activeTimeIndex, timePendingInput, showTimeOptions, commitTimeOption]); // --- Clear --- const handleClear = useCallback((e?: React.MouseEvent) => { setNativeTimeDraft(undefined); fireChange(undefined); if (!usesNativePicker) { if (!e || e.detail === 0) { dateInputRef.current?.focus(); } else { // Defer focus restoration past the button's unmount task so iOS Safari // and touch browsers don't jump the page scroll to 0 on tap. requestAnimationFrame(() => { dateInputRef.current?.focus({ preventScroll: true }); }); } } }, [fireChange, usesNativePicker]); // Focus time input when clicking wrapper padding/icon const { onClick: handleTimeWrapperClick, onMouseUp: handleTimeWrapperMouseUp } = useInputContainer({ containerRef: timeContainerRef, inputRef: timeInputRef, disabled: isEffectivelyDisabled }); return
{/* Date input */}
{usesNativePicker ? : <> {/* Live region announcing invalid typed date input to assistive technology. The value silently reverts on blur, so without this a screen-reader user would get no feedback that their entry was rejected (WCAG 3.3.1). */} {!isDateInputValid ? t('@astryx.dateInput.invalidDate') : ''} } {hasClear && value !== undefined && !isEffectivelyDisabled && } {isBusy && } {statusIcon}
{/* Time input */}
{usesNativeTimePicker ? : <>
= 0 && activeTimeIndex < timeOptions.length ? timeOptionId(activeTimeIndex) : undefined} // With a disabledMessage the input keeps focusability via // aria-disabled so the reason is focus-discoverable; typing is // blocked with readOnly and the mutation guards. disabled={isEffectivelyDisabled && !showsDisabledMessage} aria-disabled={showsDisabledMessage ? 'true' : undefined} readOnly={showsDisabledMessage || undefined} aria-label={resolvedTimeLabel} aria-describedby={ariaDescribedBy} aria-required={isEffectivelyRequired ? 'true' : undefined} aria-invalid={status?.type === 'error' || !isTimeInputValid ? 'true' : undefined} aria-busy={isBusy || undefined} {...{ 0: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xeyghm5" }, 2: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xeyghm5 xt0e3qv" }, 1: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx xjbqb8w x1a2a7pz xeyghm5 xv1l7n4" }, 3: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx xjbqb8w x1a2a7pz xeyghm5 xt0e3qv xv1l7n4" } }[Number(!!isEffectivelyDisabled) << 1 | Number(!!!isTimeInputValid) << 0]} /> {/* Live region announcing invalid typed time input to assistive technology (WCAG 3.3.1). */} {!isTimeInputValid ? t('@astryx.timeInput.invalidTime') : ''} }
{!usesNativePicker && popover.render( handleDateChange(d, 'calendar')} min={calendarMin} max={calendarMax} dateConstraints={dateConstraints} numberOfMonths={numberOfMonths} weekStartsOn={weekStartsOn} />, { placement: 'below', alignment: 'start' })} {hasTimeOptions && timePopover.render( // The layer renders its children open or closed, so mount the list // only while it is open — a 5-minute cadence is 288 nodes that would // otherwise sit in the DOM of every opted-in field for its lifetime. !timePopover.isOpen ? null :
{timeOptions.map((option, index) => { const isSelected = option.time === selectedOptionTime; return
e.preventDefault()} onClick={() => commitTimeOption(option.time)} onMouseEnter={() => highlightTimeOnHover(index)} {...mergeProps(themeProps('date-time-input-time-option'), stylex.props(styles.timeOption, timeOptionSizeStyles[size], index === activeTimeIndex && styles.timeOptionHighlighted, isSelected && styles.timeOptionSelected))}> {option.label}
; })}
, { placement: 'below', alignment: 'start' })} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } PointerDateTimeField.displayName = 'PointerDateTimeField'; /** * A combined date and time picker whose `nativePicker` prop chooses both * surfaces. Native modes use OS-owned date and time controls in Astryx field * chrome. With `nativePicker="never"`, fine pointers keep the typed fields and * popovers while coarse pointers get Astryx's coordinated Date/Time bottom * sheet. */ export function DateTimeInput({ nativePicker = 'touch', ...props }: DateTimeInputProps) { const isTouch = useMediaQuery(TOUCH_POINTER_QUERY); const usesNativePicker = nativePicker === 'always' || nativePicker === 'touch' && isTouch; return usesNativePicker || !isTouch ? : ; } DateTimeInput.displayName = 'DateTimeInput';