// 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 &&
{overlayText}
}
{/*
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)}