// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TimeInput.tsx * @input Uses React, Field, NativeTimeSegment, InputGroupContext, pointer media queries, and shared time utilities * @output Exports TimeInput, TimeInputProps, and TimeInputNativePicker * @position Core implementation; consumed by index.ts, tested by TimeInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/TimeInput/TimeInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/TimeInput/TimeInput.test.tsx (typed-field tests) * - /packages/core/src/TimeInput/NativeTimeInput.test.tsx (native-picker tests) * - /packages/core/src/TimeInput/index.ts (exports if types change) * - /apps/storybook/stories/TimeInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/TimeInput/ (showcase blocks) */ import { useId, useState, useCallback, useEffect, useRef, useMemo, useOptimistic, useTransition, type KeyboardEvent, type FocusEvent } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, typographyVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles, type FieldStatusVariant } from "../Field/index.js"; import { Icon } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { type ISOTimeString, parseTimeInput, formatDisplayTime12h, formatDisplayTime24h, formatISOTime, adjustTime, isImeKeyEvent, isTimeInRange, mergeProps, getInputARIA } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useInputContainer } from "../hooks/useInputContainer.js"; import { useMediaQuery } from "../hooks/useMediaQuery.js"; import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useInputGroup } from "../InputGroup/InputGroupContext.js"; import { groupStyles } from "../InputGroup/groupStyles.js"; import { useTooltip } from "../Tooltip/index.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { NativeTimeSegment } from "../DateTimeInput/NativeTimeSegment.js"; const TOUCH_POINTER_QUERY = '(pointer: coarse)'; const sizeStyles = { sm: { kZKoxP: "x6k0iem", k7Eaqz: "x1i9suas", $$css: true as const }, md: { kZKoxP: "x1ueg155", k7Eaqz: "x1i9suas", $$css: true as const }, lg: { kZKoxP: "xssyfek", k7Eaqz: "x1i9suas", $$css: true as const } }; export type TimeInputSize = keyof typeof sizeStyles; export type TimeInputHourFormat = '12h' | '24h'; /** Which surface TimeInput uses for time selection. */ export type TimeInputNativePicker = 'touch' | 'always' | 'never'; // Re-export shared types for convenience export type { InputStatus as TimeInputStatus, InputStatusType as TimeInputStatusType } from "../Field/index.js"; export interface TimeInputProps extends Omit { /** Ref forwarded to the root 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 field stays focusable (via `aria-disabled`) * so the reason is discoverable by keyboard and assistive technology. * Typing and arrow-key adjustment 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 time in ISO format (HH:MM or HH:MM:SS). */ value?: ISOTimeString; /** * Callback fired when the time changes. * Called with undefined when input is cleared. */ onChange?: (value: ISOTimeString | undefined) => void; /** * Async action on change. Fires after onChange. */ changeAction?: (value: ISOTimeString | undefined) => void | Promise; /** * Whether the input is in a loading state. * @default false */ isLoading?: boolean; /** * Minimum selectable time in ISO format. */ min?: ISOTimeString; /** * Maximum selectable time in ISO format. */ max?: ISOTimeString; /** * Whether to include seconds in the time input. * @default false */ hasSeconds?: boolean; /** * Whether to show a clear button when a value is set. * @default false */ hasClear?: boolean; /** * Whether to automatically focus the input on mount. * @default false */ hasAutoFocus?: boolean; /** * Hour format for display. * - '12h': Display as 12-hour with AM/PM (e.g., "2:30 PM") * - '24h': Display as 24-hour (e.g., "14:30") * @default '12h' */ hourFormat?: TimeInputHourFormat; /** * Increment in minutes when using arrow keys. * @default 1 */ increment?: number; /** * Which time-selection surface to use. * * - `'touch'`: browser/OS picker on coarse pointers, Astryx's typed field on * fine pointers * - `'always'`: browser/OS picker wherever `` is supported * - `'never'`: Astryx's typed field everywhere * * Native time pickers cannot preserve seconds or Astryx's arrow-key cadence, * so `hasSeconds` or `increment !== 1` keeps the typed field. * @default 'touch' */ nativePicker?: TimeInputNativePicker; /** * Placeholder text shown when no time is selected. * @default "Select a time" */ placeholder?: string; /** * The size of the input. * - 'sm': Compact size (18px height) * - 'md': Default size (26px height) * @default 'md' */ size?: TimeInputSize; /** * Status indicator for the input. * When set, displays a colored border and status icon. * If message is provided, displays below the input. */ status?: InputStatus; /** * How the status message is placed relative to the input. * - 'attached': message overlaps directly below the input (bordered treatment) * - 'detached': message floats below as a separate element with spacing * - 'tooltip': no message box; the status icon becomes a focusable info-tip button that reveals the message on hover, keyboard focus, or tap * @default 'attached' */ statusVariant?: FieldStatusVariant; /** * 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; } /** * A time input component with text input and keyboard navigation. * * @example * ``` * * ``` */ export function TimeInput({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, disabledMessage, value, onChange, changeAction, isLoading = false, min, max, hasSeconds = false, hasClear = false, hasAutoFocus = false, hourFormat = '12h', increment = 1, nativePicker = 'touch', placeholder: placeholderFromProps, size: sizeProp, status, statusVariant = 'attached', labelTooltip, width, xstyle, className, style, ref }: TimeInputProps) { const t = useTranslator(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.timeInput.placeholder'); const size = useSize(sizeProp, 'md'); const isTouch = useMediaQuery(TOUCH_POINTER_QUERY); const requestsNativePicker = nativePicker === 'always' || nativePicker === 'touch' && isTouch; // iOS has no seconds wheel and treats step as validation rather than wheel // cadence. Preserve those explicit Astryx contracts instead of degrading them. const usesNativeTimePicker = requestsNativePicker && !hasSeconds && increment === 1; const id = useId(); const inputLabelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const inputRef = useRef(null); const mergedInputRef = useMergedRefs(ref, inputRef); const containerRef = useRef(null); const inputGroup = useInputGroup(); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || optimisticValue !== value; // In grouped mode the status message renders as a visually-hidden node that // exists only for aria-describedby. Announce it through the persistent // useAnnounce live regions instead of role/aria-live on that node — a live // region mounted together with its content is not reliably announced. // Ungrouped mode delegates to Field -> FieldStatus, which announces itself. const announce = useAnnounce(); useEffect(() => { if (inputGroup && status?.message) { announce(status.message, status.type === 'error' ? 'assertive' : 'polite'); } }, [announce, inputGroup, status?.message, status?.type]); // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the input container (which already exists) and // the input stays perceivable via aria-disabled instead of the disabled // attribute. Typing is blocked with readOnly and value mutation guards. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container div is not naturally focusable; focusin bubbles up from // the input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // Status icon mapping const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant, isInGroup: !!inputGroup }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelID, [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, // The tooltip variant renders no message box; describe the input by the // tooltip's content instead so the status is still announced. statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); // Pending input while user is typing (null = show formatted value) const [pendingInput, setPendingInput] = useState(null); const [isFocused, setIsFocused] = useState(false); // Format function based on hourFormat const formatDisplayTime = hourFormat === '12h' ? formatDisplayTime12h : formatDisplayTime24h; // Unified change handler that fires both onChange and changeAction const fireChange = useCallback((newValue: ISOTimeString | undefined) => { onChange?.(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [onChange, changeAction, startTransition, setOptimisticValue]); // Display value: pending input if typing, otherwise formatted value const displayValue = useMemo(() => { if (pendingInput !== null) { return pendingInput; } return optimisticValue ? formatDisplayTime(optimisticValue, hasSeconds) : ''; }, [pendingInput, optimisticValue, formatDisplayTime, hasSeconds]); // Check if current input is valid (for styling purposes) const isInputValid = useMemo(() => { // Only check pending input for validity styling if (pendingInput === null || !pendingInput.trim()) { return true; } const parsed = parseTimeInput(pendingInput, hasSeconds); if (!parsed) { return false; } // Also check min/max range return isTimeInRange(parsed, min, max); }, [pendingInput, hasSeconds, min, max]); // Placeholder that shows format hint when focused and empty const displayPlaceholder = useMemo(() => { if (isFocused && !displayValue) { return hourFormat === '12h' ? 'e.g., 2:30 PM' : 'e.g., 14:30'; } return placeholder; }, [isFocused, displayValue, hourFormat, placeholder]); // Handle input text change - update immediately if valid const handleInputChange = useCallback((e: React.ChangeEvent) => { // With a disabledMessage the input drops `disabled` for focusability, so // guard value mutation explicitly (readOnly also blocks typing). if (isDisabled) { return; } const newValue = e.target.value; setPendingInput(newValue); // If the input is valid, update immediately (don't wait for blur) const parsed = parseTimeInput(newValue, hasSeconds); if (parsed && isTimeInRange(parsed, min, max) && parsed !== value) { fireChange(parsed); } }, [hasSeconds, min, max, value, fireChange, isDisabled]); // Handle focus const handleFocus = useCallback(() => { // A disabled 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 (isDisabled) { return; } setIsFocused(true); }, [isDisabled]); // Handle blur - validate and clear pending input const handleBlur = useCallback((_e: FocusEvent) => { setIsFocused(false); if (pendingInput === null) { return; } if (!pendingInput.trim()) { // Empty input clears the value if (value !== undefined) { fireChange(undefined); } setPendingInput(null); return; } const parsed = parseTimeInput(pendingInput, hasSeconds); if (parsed && isTimeInRange(parsed, min, max)) { // Valid time - update if different if (parsed !== value) { fireChange(parsed); } } // Clear pending input - display will revert to formatted value setPendingInput(null); }, [pendingInput, value, fireChange, hasSeconds, min, max]); // Handle keyboard navigation on input const handleInputKeyDown = 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; } // Arrow-key adjustment mutates the value; block it while showing a // disabled reason (the input keeps focusability via aria-disabled). if (isDisabled) { return; } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); // Get current time or default to now let currentTime = value; if (!currentTime) { const now = new Date(); currentTime = formatISOTime({ hour: now.getHours(), minute: now.getMinutes(), second: now.getSeconds() }, hasSeconds); } const delta = e.key === 'ArrowUp' ? increment : -increment; const newTime = adjustTime(currentTime, delta, hasSeconds); // Check if within range if (isTimeInRange(newTime, min, max)) { fireChange(newTime); // Stepping programmatically rewrites a plain textbox's value, and // screen readers do not announce programmatic textbox changes — the // new value must be spoken explicitly or stepping is silent // (WCAG 4.1.2). announce(formatDisplayTime(newTime, hasSeconds)); } } }, [value, hasSeconds, increment, min, max, fireChange, isDisabled, announce, formatDisplayTime]); // Handle clear button click const handleClear = useCallback((e?: React.MouseEvent) => { fireChange(undefined); // Focusing a native time control reopens the OS picker on iOS. if (!usesNativeTimePicker) { if (!e || e.detail === 0) { inputRef.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(() => { inputRef.current?.focus({ preventScroll: true }); }); } } }, [fireChange, usesNativeTimePicker]); // Focus input when clicking anywhere on the wrapper (icons, padding, etc.) const { onClick: handleWrapperClick, onMouseUp: handleWrapperMouseUp } = useInputContainer({ containerRef, inputRef, disabled: isDisabled }); const inputWrapper =
{ containerRef.current = el; // Anchor + hover/focus listeners for the disabled-message tooltip. // Handlers are gated internally by isEnabled, so attaching // unconditionally is safe. disabledMessageTooltip.ref(el); }} onClick={usesNativeTimePicker ? undefined : handleWrapperClick} onMouseUp={usesNativeTimePicker ? undefined : handleWrapperMouseUp} {...mergeProps(themeProps('time-input', { size, status: status?.type ?? null, disabled: isDisabled ? 'disabled' : null }), stylex.props(inputWrapperStyles.base, sizeStyles[size], isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}> {inputGroup && {label}} {inputGroup && description && {description} } {inputGroup && status?.message && {status.message} } {usesNativeTimePicker ? : <>
{/* Live region announcing invalid typed 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). */} {!isInputValid ? t('@astryx.timeInput.invalidTime') : ''} } {isBusy && } {hasClear && optimisticValue && !isDisabled && } {statusIcon}
; if (inputGroup) { return <> {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } return {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } TimeInput.displayName = 'TimeInput';