// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file NativeDateField.tsx * @input Uses React, Field, Icon, InputClearButton, Spinner, useCalendarConstraints, hasEditableDateSegments * @output Exports NativeDateField — the OS-picker touch surface * @position One of DateInput's three surfaces. `DateInput` picks the pointer * field on a mouse and, on touch, either `TouchDateField` (the default) or * this one when the consumer opts in with `nativePicker`. * * Hands date picking to the platform: a real ``, whose * picker the OS draws — the iOS wheel, the Android calendar dialog. The field * itself still looks like every other Astryx input, and DateInput paints its * text so `format` and `placeholder` keep applying. * * Everything unusual in here was measured on a real iOS device; the comments * say which behaviour forced which decision, because none of them are * reproducible in a desktop browser. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DateInput/DateInput.tsx (the `nativePicker` prop) * - /packages/core/src/DateInput/DateInput.doc.mjs (prop table) * - /packages/core/src/DateInput/nativeDateSegments.ts (the engine probe) * - /packages/core/src/DateInput/NativeDateField.test.tsx (tests) */ import { useCallback, useEffect, useId, useRef, useState } from 'react'; import * as stylex from "../style-runtime.js"; import { useCalendarConstraints } from "../Calendar/index.js"; import type { DateInputProps } from "./DateInput.js"; import { hasEditableDateSegments } from "./nativeDateSegments.js"; import { Field, InputClearButton, inputStatusBorderStyles, inputStatusFocusWithinStyles, inputStatusHoverShadowStyles, inputWrapperStyles } from "../Field/index.js"; import { useInputStatusIcon, useMergedRefs } from "../hooks/index.js"; import { useMediaQuery } from "../hooks/useMediaQuery.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { Icon } from "../Icon/index.js"; import { useLocale, useTranslator } from "../i18n/index.js"; import { useInputGroup } from "../InputGroup/index.js"; import { groupStyles } from "../InputGroup/groupStyles.js"; import { stableClassName } from "../naming.js"; import { useSize } from "../SizeContext/index.js"; import { Spinner } from "../Spinner/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars, sizeVars, typeScaleVars, typographyVars } from "../theme/tokens.stylex.js"; import { useTooltip } from "../Tooltip/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { focusOutlineStyles, formatSharedDate, getInputARIA, mergeProps, parseDateInput, plainDateFromISO, plainDateToISO, themeProps, type ISODateString } from "../utils/index.js"; const styles = { wrapper: { kOIVth: "x167g77z", $$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 } }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", k7Eaqz: "xfb3i0g", $$css: true as const }, md: { kZKoxP: "x1ueg155", k7Eaqz: "xfb3i0g", $$css: true as const }, lg: { kZKoxP: "xssyfek", k7Eaqz: "xfb3i0g", $$css: true as const } }; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; /** * The OS-picker surface. Takes `DateInput`'s props verbatim; see * {@link DateInput} for when it is chosen over the other two. */ export function NativeDateField({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, disabledMessage, value, onChange, isLoading = false, min, max, dateConstraints, placeholder: placeholderFromProps, size: sizeProp, status, statusVariant = 'attached', labelTooltip, hasClear = false, // The OS draws the picker, so neither reaches it: both describe a calendar // grid it does not have. Accepted (the prop types are shared) and ignored. numberOfMonths: _numberOfMonths, weekStartsOn: _weekStartsOn, format = 'date_long', width, xstyle, className, style, ref, ...rest }: DateInputProps) { const t = useTranslator(); const locale = useLocale(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.dateInput.placeholder'); const size = useSize(sizeProp, 'md'); // Only breaks a tie the engine probe cannot: see ./nativeDateSegments. const isTouchPointer = useMediaQuery('(pointer: coarse)'); const id = useId(); const inputLabelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const inputRef = useRef(null); const mergedInputRef = useMergedRefs(ref, inputRef); const inputGroup = useInputGroup(); const isEffectivelyDisabled = isDisabled || isLoading; // Disabled-reason tooltip, same contract as the other two surfaces: a // disabled control swallows pointer events, so the listeners attach to the // wrapper and the input stays focusable via aria-disabled. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', focusTrigger: 'always', isEnabled: showsDisabledMessage }); const { isDateDisabled } = useCalendarConstraints({ min, max, dateConstraints }); const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant, isInGroup: !!inputGroup }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelID, [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); // A date the picker produced that `dateConstraints` refuses, held so the // refusal can be announced instead of looking like a dead tap. const [rejectedValue, setRejectedValue] = useState(null); // Whether the control has focus — which, on a touch device, means its // picker is open. const [isFocused, setIsFocused] = useState(false); // Whether the engine draws this control as editable segments rather than a // picker-only run. Latched on focus rather than read during render: the // probe touches the DOM, and unfocused the answer changes nothing. const [isSegmentEditable, setIsSegmentEditable] = useState(false); // The raw value the control last reported and we acted on, so the same edit // arriving through both commit paths only fires one change. const lastCommitRef = useRef(null); const prevValueRef = useRef(value); if (value !== prevValueRef.current) { prevValueRef.current = value; lastCommitRef.current = null; if (rejectedValue !== null) { setRejectedValue(null); } } // The control's own value is always ISO — the only form it accepts, and // what the picker reads and writes. `format` rides on the overlay instead. const nativeValue = value && ISO_DATE.test(value) ? value : ''; const isInputValid = rejectedValue === null; const formatValue = useCallback((iso: ISODateString): string => typeof format === 'function' ? format(iso) : formatSharedDate(plainDateFromISO(iso), format, locale), [format, locale]); // This field paints the closed control's text itself, which is what keeps // `format` and `placeholder` applying: a picker-only control has no // segments to edit, so our text holds even while the picker is open, and // tracks it live. An editable control is the opposite case — its text IS // the edit surface, and it reports no `value` until every segment is // filled, so the overlay has nothing to paint mid-edit — so step aside for // as long as it has focus. See ./nativeDateSegments. const overlayText = nativeValue ? formatValue(nativeValue) : placeholder; const showsOverlay = !!overlayText && !(isFocused && isSegmentEditable); const commitValue = useCallback((newValue: string) => { if (isEffectivelyDisabled) { return; } // The same edit can arrive twice — React's synthetic change and the // native listener below both report it — so act on a raw value once. if (lastCommitRef.current === newValue) { return; } lastCommitRef.current = newValue; if (!newValue) { setRejectedValue(null); if (value !== undefined) { onChange?.(undefined); } return; } const parsed = parseDateInput(newValue, locale); if (!parsed) { return; } if (isDateDisabled(parsed)) { // iOS does not enforce min/max in its picker — those attributes are // constraint-validation flags, not clamps, and the sheet lets the // user land on any date. Refuse it here and let the sync effect snap // the control back; the live region below announces the refusal. setRejectedValue(newValue); return; } setRejectedValue(null); const parsedISO = plainDateToISO(parsed); if (parsedISO !== value) { onChange?.(parsedISO); } }, [value, onChange, isDateDisabled, isEffectivelyDisabled, locale]); const handleChange = useCallback((e: React.ChangeEvent) => { commitValue(e.target.value); }, [commitValue]); // React's synthetic change system does not reliably observe the iOS // picker's edits. Measured on an iPhone: picking a date fired a native // `input` event carrying the new date while React's `onChange` never ran — // so React re-rendered and wrote its own stale value straight back over the // picker's, and the user's pick (and their Reset) silently reverted. A // native listener reads what the control actually holds, whatever React's // synthetic layer made of it. const commitRef = useRef(commitValue); useEffect(() => { commitRef.current = commitValue; }); useEffect(() => { const input = inputRef.current; if (!input) { return; } const handleNative = () => commitRef.current(input.value); input.addEventListener('input', handleNative); input.addEventListener('change', handleNative); return () => { input.removeEventListener('input', handleNative); input.removeEventListener('change', handleNative); }; }, []); // The value the control mounts with. Deliberately captured once: React // writes to the element whenever a `value` OR `defaultValue` prop changes, // and on iOS ANY write while the picker sheet is open detaches the sheet // from the field — the wheel and Reset keep moving the sheet's own // highlight, but nothing they do reaches the input and no event fires, so // the user's pick appears to do nothing. Holding this constant means React // touches the element exactly once, at mount; the effect below owns every // later update and only writes while the field is unfocused. const initialValueRef = useRef(null); if (initialValueRef.current === null) { initialValueRef.current = nativeValue; } // Push an externally-changed value in — but never while the control has // focus, for the reason above. Blur flips `isFocused`, so this doubles as // the reconcile once the picker closes. (Self-heals a stale // `initialValueRef` too, on the commit right after a remount.) useEffect(() => { if (isFocused) { return; } const input = inputRef.current; if (input && input.value !== nativeValue) { input.value = nativeValue; } }, [isFocused, nativeValue]); const handleFocus = useCallback(() => { setIsSegmentEditable(hasEditableDateSegments(isTouchPointer)); setIsFocused(true); }, [isTouchPointer]); const handleBlur = useCallback(() => { const domValue = inputRef.current?.value; setIsFocused(false); // A refused date is reverted by the sync effect the moment focus leaves, // so the field is once again showing a date that IS valid. Keeping the // rejection past that point would mark good data invalid, with no way // back except changing the field again. The live region announced the // refusal while it happened; that is the feedback. setRejectedValue(null); if (domValue !== undefined && domValue !== nativeValue) { commitValue(domValue); } }, [commitValue, nativeValue]); // Focusing a date control is what raises the OS picker, so the usual // focus-restore after a clear would pop the picker the tap just dismissed — // and on iOS that reads as the clear having done nothing. const handleClear = useCallback(() => { onChange?.(undefined); }, [onChange]); const openPicker = useCallback(() => { if (isEffectivelyDisabled) { return; } const input = inputRef.current; if (!input) { return; } // Focus first: on touch browsers focusing the control is itself what // raises the picker, and iOS implements no `showPicker()` for type=date // (WebKit bug 261703), so focus is the whole mechanism there. input.focus(); if (typeof input.showPicker === 'function') { try { input.showPicker(); } catch { // showPicker throws without transient user activation and inside a // cross-origin iframe. The focus above is the fallback. } } }, [isEffectivelyDisabled]); const inputWrapper =
{ disabledMessageTooltip.ref(el); }} {...rest} {...mergeProps(themeProps('date-input', { size, status: status?.type ?? null, disabled: isDisabled ? 'disabled' : null }), stylex.props(inputWrapperStyles.base, sizeStyles[size], styles.wrapper, isEffectivelyDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isEffectivelyDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}> {inputGroup && {label}} {showsOverlay && } {/* Live region announcing a refused date. The value snaps back on its own, so without this a screen-reader user would get no feedback that their pick was rejected (WCAG 3.3.1). */} {!isInputValid ? t('@astryx.dateInput.invalidDate') : ''} {hasClear && value !== undefined && !isEffectivelyDisabled && } {isLoading && } {statusIcon} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; if (inputGroup) { return inputWrapper; } return {inputWrapper} ; } NativeDateField.displayName = 'NativeDateField';