diff --git a/.changeset/date-input-native-touch-picker.md b/.changeset/date-input-native-touch-picker.md
new file mode 100644
index 000000000000..2408f1ea8085
--- /dev/null
+++ b/.changeset/date-input-native-touch-picker.md
@@ -0,0 +1,30 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] DateInput: on a touch device the field now opens the browser/OS date
+picker instead of the in-page calendar
+
+A phone tapping the calendar toggle got a 320px month grid in a popover: a
+desktop control shrunk onto a screen where the platform already ships a date
+picker every user knows, with system-sized hit areas, momentum scrolling, and
+the OS locale and accessibility settings applied for free.
+
+DateInput now renders `` when the pointer is coarse, so iOS
+shows its wheel and Android its calendar dialog. The new `nativePicker` prop
+takes `'touch'` (the default — native on touch), `'always'`, or `'never'` for a
+field that must look identical on every device. The switch is client-side, so
+SSR still renders the text field; a browser without `type="date"` support keeps
+it too.
+
+In native mode the browser owns the picker, so `numberOfMonths` and
+`weekStartsOn` no longer apply — they describe a calendar grid the native
+picker does not have. `format` and `placeholder` do still apply: DateInput
+paints the closed field's text itself, over the control, so a date reads the
+same on a phone as on a desktop. (A desktop control whose segments the browser
+lets you type into hands its text back while focused; a touch picker has no
+segments, so ours holds throughout.) `min` and `max` carry over;
+`dateConstraints` cannot — a native picker only expresses a contiguous range —
+so `'touch'` keeps the Calendar popover whenever constraints are set.
+
+@imdreamrunner
diff --git a/apps/storybook/stories/DateInput.stories.tsx b/apps/storybook/stories/DateInput.stories.tsx
index ccf81fd20e1e..f0e8c526390e 100644
--- a/apps/storybook/stories/DateInput.stories.tsx
+++ b/apps/storybook/stories/DateInput.stories.tsx
@@ -85,6 +85,12 @@ const meta: Meta = {
description:
"Display format for the committed value, reusing Timestamp's vocabulary. Defaults to 'date_long' (long-month date).",
},
+ nativePicker: {
+ control: 'radio',
+ options: ['touch', 'always', 'never'],
+ description:
+ "Whether the browser/OS date picker replaces the Calendar popover. 'touch' (default) switches on touch devices.",
+ },
},
};
@@ -438,6 +444,54 @@ export const AllVariations: Story = {
},
};
+/**
+ * On a touch device DateInput hands date picking to the browser, rendering
+ * `` so the platform's own picker opens — the iOS wheel,
+ * the Android calendar dialog. Open this story on a phone, or in a desktop
+ * browser's device emulation (which reports a coarse pointer), to see the
+ * default field switch. `format` still applies: DateInput paints the closed
+ * field's text over the control, so all three fields below read alike.
+ */
+export const NativePicker: Story = {
+ name: 'Native picker on touch',
+ render: () => {
+ const [touch, setTouch] = useState('2026-03-21');
+ const [always, setAlways] = useState(
+ '2026-03-21',
+ );
+ const [never, setNever] = useState('2026-03-21');
+ return (
+
+
+
+
+
+ );
+ },
+};
+
export const Clearable: Story = {
render: args => {
const [value, setValue] = useState('2026-04-06');
diff --git a/packages/core/src/DateInput/DateInput.doc.mjs b/packages/core/src/DateInput/DateInput.doc.mjs
index 19c31eb8decb..b5a62a5d0185 100644
--- a/packages/core/src/DateInput/DateInput.doc.mjs
+++ b/packages/core/src/DateInput/DateInput.doc.mjs
@@ -156,6 +156,13 @@ export const docs = {
"How the committed date value is displayed. Named values are reused from Timestamp's format vocabulary: 'date' shows 'Mar 21, 2026', 'date_long' shows 'March 21, 2026', 'date_weekday' shows 'Wed, Mar 21, 2026', 'system_date' shows '2026-03-21'. A function receives the ISO value and returns a custom string. Applies only to the committed value, never to text being typed.",
default: "'date_long'",
},
+ {
+ name: 'nativePicker',
+ type: "'touch' | 'always' | 'never'",
+ description:
+ "Whether date picking is handed to the browser/OS instead of the built-in Calendar popover. 'touch' uses the native control on touch devices (coarse pointer) and the Calendar popover on mouse-driven ones; 'always' uses it wherever the browser supports input type=date; 'never' always uses the Calendar popover. In native mode the browser owns the picker, so numberOfMonths and weekStartsOn no longer apply — they describe a calendar grid the native picker does not have. format and placeholder still apply: DateInput paints the closed field's text itself, over the control. On a desktop control, whose segments the browser lets the user type into, the field reverts to the browser's format while focused; a touch picker has no segments, so our text holds throughout. min and max carry over but dateConstraints cannot, so 'touch' keeps the Calendar popover whenever dateConstraints is set.",
+ default: "'touch'",
+ },
{
name: 'width',
type: 'SizeValue',
@@ -236,7 +243,7 @@ export const docs = {
name: 'Text input',
required: true,
description:
- 'A field where the user can type a date directly. Parses common formats like MM/DD/YYYY.',
+ 'A field where the user can type a date directly. Parses common formats like MM/DD/YYYY. On touch devices it becomes a native date control the OS picker fills in.',
},
{
name: 'Calendar icon',
@@ -248,7 +255,7 @@ export const docs = {
name: 'Calendar popover',
required: false,
description:
- 'A month grid that appears when the icon is clicked or the input is focused.',
+ 'A month grid that appears when the icon is clicked or the input is focused. On touch devices the browser/OS date picker takes its place.',
},
{
name: 'Clear button',
@@ -437,6 +444,13 @@ export const docsZh = {
"已选日期的显示格式。命名值复用 Timestamp 的格式词汇:'date' 显示 'Mar 21, 2026','date_long' 显示 'March 21, 2026','date_weekday' 显示 'Wed, Mar 21, 2026','system_date' 显示 '2026-03-21'。函数接收 ISO 值并返回自定义字符串。仅作用于已提交的值,不影响正在输入的文本。",
default: "'date_long'",
},
+ {
+ name: 'nativePicker',
+ type: "'touch' | 'always' | 'never'",
+ description:
+ "是否将日期选择交给浏览器/操作系统,而非内置的日历弹出层。'touch' 在触摸设备(粗指针)上使用原生控件,在鼠标设备上使用日历弹出层;'always' 在支持 input type=date 的浏览器上始终使用原生控件;'never' 始终使用日历弹出层。原生模式下选择器由浏览器提供,因此 numberOfMonths 和 weekStartsOn 不再生效(它们描述的是原生选择器没有的日历网格)。format 和 placeholder 仍然生效:DateInput 会在控件上方自行绘制关闭状态下的文本。在允许键入日期分段的桌面端控件上,聚焦时会交还给浏览器自身的格式;触摸设备的选择器没有分段,因此始终显示我们的文本。min 和 max 会传递给原生控件,但 dateConstraints 无法传递,所以设置 dateConstraints 时 'touch' 会保留日历弹出层。",
+ default: "'touch'",
+ },
{
name: 'xstyle',
type: 'StyleXStyles',
@@ -459,7 +473,8 @@ export const docsZh = {
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
export const docsDense = {
- description: 'text input w/ calendar popover for picking a date',
+ description:
+ 'text input w/ calendar popover for picking a date; native OS picker on touch',
usage: {
description:
'DateInput lets the user type or pick a date from a calendar popover. Use for scheduling, deadlines, booking dates, or any form field needing a calendar date.',
@@ -537,6 +552,8 @@ export const docsDense = {
weekStartsOn: 'first day of week in calendar (0=Sunday, or name e.g. "mon")',
format:
"committed-value display: 'date_long' (default, March 21, 2026), 'date' (Mar 21, 2026), 'date_weekday' (Wed, Mar 21, 2026), 'system_date' (2026-03-21), or (iso)=>string; reuses Timestamp vocabulary. Committed value only, not while typing.",
+ nativePicker:
+ "browser/OS date picker instead of the Calendar popover: 'touch' (default) = native on touch (coarse pointer), 'always', 'never'. Native mode ignores numberOfMonths/weekStartsOn; format+placeholder still apply (DateInput paints the closed field over the control; a segment-editable desktop control reverts to the browser format while focused). min+max carry over, dateConstraints keeps 'touch' on the popover.",
xstyle: 'StyleX styles for layout; must be stylex.create() value',
},
};
diff --git a/packages/core/src/DateInput/DateInput.tsx b/packages/core/src/DateInput/DateInput.tsx
index 5adf3e6ddb7b..7aef4295fa06 100644
--- a/packages/core/src/DateInput/DateInput.tsx
+++ b/packages/core/src/DateInput/DateInput.tsx
@@ -4,7 +4,7 @@
/**
* @file DateInput.tsx
- * @input Uses React, useId, useState, useCallback, useRef, Field, Icon, Calendar, usePopover, InputGroupContext
+ * @input Uses React, useId, useState, useCallback, useRef, Field, Icon, Calendar, usePopover, InputGroupContext, useNativeDatePicker
* @output Exports DateInput component, DateInputProps
* @position Core implementation; consumed by index.ts, tested by DateInput.test.tsx
*
@@ -20,6 +20,7 @@ import {
useId,
useState,
useCallback,
+ useEffect,
useRef,
useOptimistic,
useTransition,
@@ -57,6 +58,10 @@ import {
type DayOfWeekName,
} from '../Calendar';
import {useCalendarConstraints} from '../Calendar/hooks';
+import {
+ useNativeDatePicker,
+ type DateInputNativePicker,
+} from './useNativeDatePicker';
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
import {useResolvedRequired} from '../hooks/useResolvedRequired';
import {usePopover} from '../Popover';
@@ -111,6 +116,103 @@ const styles = stylex.create({
inputInvalid: {
color: colorVars['--color-text-secondary'],
},
+ // ``, rendered when the browser/OS date picker takes over
+ // (see useNativeDatePicker). The engine draws its own chrome inside the
+ // field; these rules make it sit like the text variant it replaces.
+ nativeInput: {
+ // A date control's intrinsic height comes from its inner edit fields, not
+ // from `line-height`, so it is ~2px taller than the text input and its
+ // value paints a pixel off the text variant's baseline inside the same
+ // flex row. One line box is exactly what the text input occupies.
+ height: stylex.firstThatWorks(
+ '1lh',
+ `calc(max(1rem, ${typeScaleVars['--text-body-size']}) * ${typeScaleVars['--text-body-leading']})`,
+ ),
+ // iOS gives date controls their own button-like chrome, with inner
+ // spacing and a centered value that no reset of ours can reach.
+ WebkitAppearance: 'none',
+ appearance: 'none',
+ // Chromium paints a second calendar glyph inside the field. DateInput
+ // already ships a toggle button, so drop the duplicate.
+ '::-webkit-calendar-picker-indicator': {
+ display: 'none',
+ },
+ // iOS Safari centers the value and reserves its own inner spacing.
+ '::-webkit-date-and-time-value': {
+ textAlign: 'start',
+ marginBlock: 0,
+ marginInline: 0,
+ paddingBlock: 0,
+ paddingInline: 0,
+ lineHeight: 'inherit',
+ minHeight: 0,
+ },
+ '::-webkit-datetime-edit': {
+ paddingBlock: 0,
+ paddingInline: 0,
+ lineHeight: 'inherit',
+ },
+ },
+ // Hides whatever the engine paints inside the control so DateInput's own
+ // text can take that space. WebKit renders the value into a single
+ // `::-webkit-date-and-time-value` run which the UA stylesheet gives no
+ // colour of its own (the iOS UA colour, -apple-system-blue, sits on the
+ // INPUT), so it inherits this; Chromium's `::-webkit-datetime-edit` fields
+ // inherit it too. `-webkit-text-fill-color` is what actually wins inside a
+ // WebKit date control.
+ nativeInputTextHidden: {
+ color: 'transparent',
+ WebkitTextFillColor: 'transparent',
+ },
+ // DateInput's own text, laid over the control. Decorative: the input still
+ // holds the value and keeps its label, description, and status wiring, so
+ // announcing this too would just double-speak.
+ nativeOverlay: {
+ position: 'absolute',
+ insetInlineStart: 0,
+ // Both insets, so the overlay is bounded by the slot rather than
+ // shrink-to-fit. Without the end inset a long formatted date (the
+ // default `date_long` renders up to "September 30, 2026") paints past
+ // the slot and over whatever follows it in the field — measured running
+ // 24px across the clear button.
+ insetInlineEnd: 0,
+ insetBlock: 0,
+ // A BLOCK box, not a flex one: `text-overflow` only applies to a block
+ // container, so on a flex container a too-long date hard-clips mid-glyph
+ // instead of ellipsising (measured identical to `text-overflow: clip` in
+ // both WebKit and Chromium). Centring then has to come from the line box
+ // rather than `align-items`, so the overlay carries the same font size
+ // and leading as the input it covers: one line of that leading fills the
+ // overlay's height exactly, which puts the glyphs on the input's own
+ // baseline. Without this the text sat ~2.4px high.
+ display: 'block',
+ fontSize: {
+ default: typeScaleVars['--text-body-size'],
+ '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--text-body-size']})`,
+ },
+ lineHeight: typeScaleVars['--text-body-leading'],
+ // A tap has to reach the control underneath — that is what raises the
+ // picker.
+ pointerEvents: 'none',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ },
+ nativeOverlayValue: {
+ color: colorVars['--color-text-primary'],
+ },
+ nativeOverlayPlaceholder: {
+ color: colorVars['--color-text-secondary'],
+ },
+ // Positioning context for the placeholder overlay, standing in for the
+ // input's own box in the field's flex row.
+ nativeInputSlot: {
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'center',
+ flex: 1,
+ minWidth: 0,
+ },
});
const sizeStyles = stylex.create({
@@ -155,6 +257,7 @@ export type {
InputStatus as DateInputStatus,
InputStatusType as DateInputStatusType,
} from '../Field';
+export type {DateInputNativePicker} from './useNativeDatePicker';
import {mergeProps, mergeRefs, isFocusDetached} from '../utils';
import type {BaseProps} from '../BaseProps';
import type {SizeValue} from '../utils/types';
@@ -354,6 +457,40 @@ export interface DateInputProps extends Omit<
* ```
*/
format?: DateInputFormat | ((value: ISODateString) => string);
+
+ /**
+ * Whether date picking is handed to the browser/OS instead of the built-in
+ * Calendar popover. A native control gives touch users the picker their
+ * platform already teaches — the iOS wheel, the Android calendar dialog —
+ * with system-sized hit areas, momentum scrolling, and the OS locale and
+ * accessibility settings applied.
+ *
+ * - `'touch'` (default): native on touch devices (coarse pointer), the
+ * Calendar popover on mouse-driven ones
+ * - `'always'`: native wherever the browser supports ``
+ * - `'never'`: always the Calendar popover
+ *
+ * The switch is made on the client after hydration, so the server always
+ * renders the text field. In native mode the browser owns the picker, so
+ * `numberOfMonths` and `weekStartsOn` no longer apply — they describe a
+ * calendar grid the native picker does not have. `format` and `placeholder`
+ * still apply: DateInput paints the closed field's text itself, over the
+ * control. On a desktop control, whose date segments the browser lets the
+ * user type into, the field reverts to the browser's own format while it
+ * has focus; a touch picker has no segments, so our text holds throughout.
+ * The picker's own surface is always the OS locale's. `min` and `max` carry
+ * over to the native control; `dateConstraints` cannot (a native picker
+ * only expresses a contiguous range), so `'touch'` keeps the Calendar
+ * popover whenever `dateConstraints` is set.
+ *
+ * @default 'touch'
+ * @example
+ * ```
+ * // Keep the in-page calendar everywhere, including on phones
+ *
+ * ```
+ */
+ nativePicker?: DateInputNativePicker;
}
/**
@@ -392,6 +529,7 @@ export function DateInput({
numberOfMonths = 1,
weekStartsOn,
format = 'date_long',
+ nativePicker = 'touch',
width,
xstyle,
className,
@@ -413,6 +551,13 @@ export function DateInput({
const lastFiredValueRef = useRef(undefined);
const inputGroup = useInputGroup();
+ // Touch devices get the browser/OS date picker instead of the Calendar
+ // popover: a `` whose picker the platform draws.
+ const {isNative, isSegmentEditable} = useNativeDatePicker(
+ nativePicker,
+ (dateConstraints?.length ?? 0) > 0,
+ );
+
const [, startTransition] = useTransition();
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const isBusy = isLoading || optimisticValue !== value;
@@ -460,11 +605,42 @@ export function DateInput({
// Pending input while user is typing (null = show formatted value)
const [pendingInput, setPendingInput] = useState(null);
+ // Native mode has no pending text — the control only ever hands back a
+ // complete date. This holds a date the picker produced that `dateConstraints`
+ // refuses (reachable only with nativePicker="always", since 'touch' keeps the
+ // Calendar popover whenever constraints are set), so the refusal can be
+ // announced instead of looking like a dead tap.
+ const [rejectedNativeValue, setRejectedNativeValue] = useState(
+ null,
+ );
+
+ // Whether the native control has focus. Its empty-state hint is hidden so
+ // DateInput's own placeholder can take that space, and focus is what brings
+ // the hint back — the user is about to scroll or type into those segments.
+ const [isNativeFocused, setIsNativeFocused] = useState(false);
+
+ // Whether a key has gone into the native control since it took focus.
+ //
+ // Backstop for a device the pointer check gets wrong: a Windows tablet
+ // reports a coarse pointer while desktop Chrome still renders editable
+ // segments. Focus alone would not hand the field back there, and the user
+ // would be typing into digits they cannot see. A picker-only control never
+ // receives a keystroke, so this stays false on a phone.
+ const [hasTypedSinceFocus, setHasTypedSinceFocus] = useState(false);
+
+ // The raw value the native control last reported and we acted on, so the
+ // same edit arriving through both commit paths only fires one change.
+ const lastNativeCommitRef = useRef(null);
+
// Clear pending input when value changes externally (computed during render
// via prev-value ref instead of useEffect to avoid an extra render cycle)
const prevValueRef = useRef(value);
if (value !== prevValueRef.current) {
prevValueRef.current = value;
+ lastNativeCommitRef.current = null;
+ if (rejectedNativeValue !== null) {
+ setRejectedNativeValue(null);
+ }
if (value !== lastFiredValueRef.current) {
lastFiredValueRef.current = undefined;
if (pendingInput !== null) {
@@ -495,11 +671,40 @@ export function DateInput({
? formatCommittedValue(optimisticValue)
: '';
+ // The native control's own value is always ISO — that is the only form it
+ // accepts, and what the picker reads and writes. `format` rides on the
+ // overlay below instead, so the value the engine holds stays canonical.
+ const nativeValue =
+ optimisticValue && /^\d{4}-\d{2}-\d{2}$/.test(optimisticValue)
+ ? optimisticValue
+ : '';
+
+ // The engine owns the field's text only while the user is editing its
+ // segments; the rest of the time DateInput paints it, which is what keeps
+ // `format` applying in native mode. On a picker-only control — the iOS
+ // wheel, the Android dialog — there are no segments, so both terms stay
+ // false and our text holds even while the picker is open.
+ const isEditingNative =
+ (isSegmentEditable && isNativeFocused) || hasTypedSinceFocus;
+ const paintsOwnText = isNative && !isEditingNative;
+
+ // What goes over the control: the formatted value, or the placeholder when
+ // there is nothing to format.
+ const nativeOverlayText = nativeValue
+ ? formatCommittedValue(nativeValue)
+ : placeholder;
+ const showsNativeOverlay = paintsOwnText && !!nativeOverlayText;
+
// Check if current input is valid (for styling purposes)
- const isInputValid =
+ const isTypedInputValid =
pendingInput === null || !pendingInput.trim()
? true
: parseDateInput(pendingInput) !== null;
+ // The native control never hands back half-typed text, so the only way it
+ // holds something invalid is a date `dateConstraints` refused.
+ const isInputValid = isNative
+ ? rejectedNativeValue === null
+ : isTypedInputValid;
const popover = usePopover({
dialogLabel: t('@astryx.dateInput.dialogLabel'),
@@ -518,16 +723,36 @@ export function DateInput({
},
});
- // Handle toggling the popover from button click (focus calendar)
+ // Handle toggling the popover from button click (focus calendar).
+ // In native mode the same button asks the browser for its own picker.
const handleToggle = useCallback(() => {
- if (!isEffectivelyDisabled) {
- if (popover.isOpen) {
- popover.hide();
- } else {
- popover.show();
+ if (isEffectivelyDisabled) {
+ return;
+ }
+ if (isNative) {
+ const input = inputRef.current;
+ if (!input) {
+ return;
}
+ // Focus first: on touch browsers focusing the control is itself what
+ // raises the picker, which covers engines without showPicker.
+ 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.
+ }
+ }
+ return;
}
- }, [isEffectivelyDisabled, popover]);
+ if (popover.isOpen) {
+ popover.hide();
+ } else {
+ popover.show();
+ }
+ }, [isEffectivelyDisabled, isNative, popover]);
// Handle opening the popover from input click (keep focus in input)
const handleInputClick = useCallback(() => {
@@ -556,8 +781,14 @@ export function DateInput({
// Handle clear button click
const handleClear = useCallback(() => {
fireChange(undefined);
- inputRef.current?.focus();
- }, [fireChange]);
+ // Focusing a native date control is what raises the OS picker, so taking
+ // focus back after a clear would pop the wheel the user just dismissed —
+ // and on iOS that reads as the clear having done nothing. Only the text
+ // field, where focus restores the caret, gets it back.
+ if (!isNative) {
+ inputRef.current?.focus();
+ }
+ }, [fireChange, isNative]);
// Handle date selection from calendar
const handleDateSelect = useCallback(
@@ -597,6 +828,130 @@ export function DateInput({
[value, fireChange, isDateDisabled, isEffectivelyDisabled],
);
+ // Commit a raw value coming back from the native control. Its value is
+ // either an ISO date string or '' — the engine never reports a half-typed
+ // date, so there is no pending-text state to keep here.
+ const commitNativeValue = 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 (lastNativeCommitRef.current === newValue) {
+ return;
+ }
+ lastNativeCommitRef.current = newValue;
+
+ if (!newValue) {
+ setRejectedNativeValue(null);
+ if (value !== undefined) {
+ fireChange(undefined);
+ }
+ return;
+ }
+
+ const parsed = parseDateInput(newValue);
+ if (!parsed) {
+ return;
+ }
+ if (isDateDisabled(parsed)) {
+ // Refuse the date and let the controlled value snap the control back.
+ // The live region below announces the rejection.
+ setRejectedNativeValue(newValue);
+ return;
+ }
+
+ setRejectedNativeValue(null);
+ const parsedISO = plainDateToISO(parsed);
+ if (parsedISO !== value) {
+ lastFiredValueRef.current = parsedISO;
+ fireChange(parsedISO);
+ }
+ },
+ [value, fireChange, isDateDisabled, isEffectivelyDisabled],
+ );
+
+ const handleNativeChange = useCallback(
+ (e: React.ChangeEvent) => {
+ commitNativeValue(e.target.value);
+ },
+ [commitNativeValue],
+ );
+
+ // React's synthetic change system does not reliably observe the iOS date
+ // 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, which restores the date
+ // the field opened with) silently reverted. A native listener reads what the
+ // control actually holds, whatever React's synthetic layer made of it.
+ const commitRef = useRef(commitNativeValue);
+ useEffect(() => {
+ commitRef.current = commitNativeValue;
+ });
+ useEffect(() => {
+ if (!isNative) {
+ return;
+ }
+ 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);
+ };
+ }, [isNative]);
+
+ // The value the native 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 Reset 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 when the field is not focused.
+ const initialNativeValueRef = useRef(null);
+ if (isNative && initialNativeValueRef.current === null) {
+ initialNativeValueRef.current = nativeValue;
+ }
+
+ // Push an externally-changed value into the uncontrolled native control —
+ // but never while it has focus, for the reason above. Blur flips
+ // `isNativeFocused`, so this doubles as the reconcile once the picker
+ // closes.
+ useEffect(() => {
+ if (!isNative || isNativeFocused) {
+ return;
+ }
+ const input = inputRef.current;
+ if (input && input.value !== nativeValue) {
+ input.value = nativeValue;
+ }
+ }, [isNative, isNativeFocused, nativeValue]);
+
+ // Reconcile when the picker closes, in case an engine commits its result on
+ // dismissal without firing anything at all.
+ const handleNativeBlur = useCallback(() => {
+ const domValue = inputRef.current?.value;
+ setIsNativeFocused(false);
+ setHasTypedSinceFocus(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 — a greyed value
+ // and `aria-invalid` over a date the user never chose, with no way back
+ // except changing the field again. The live region announced the refusal
+ // while it happened; that is the feedback, not a persistent state.
+ setRejectedNativeValue(null);
+ if (domValue !== undefined && domValue !== nativeValue) {
+ commitNativeValue(domValue);
+ }
+ }, [commitNativeValue, nativeValue]);
+
// Commit pending input (shared by blur and Enter key)
const commitPendingInput = useCallback(() => {
if (pendingInput === null) {
@@ -695,7 +1050,7 @@ export function DateInput({
onClick={handleToggle}
disabled={isEffectivelyDisabled}
aria-label={
- popover.isOpen
+ !isNative && popover.isOpen
? t('@astryx.dateInput.toggleCalendarClose')
: t('@astryx.dateInput.openCalendar')
}
@@ -715,46 +1070,119 @@ export function DateInput({
// could not reach. Reflects the popover's open/closed state as a
// `data-state` attribute.
{...themeProps('date-input-toggle-icon', {
- state: popover.isOpen ? 'expanded' : 'collapsed',
+ state: !isNative && popover.isOpen ? 'expanded' : 'collapsed',
})}
/>
-
+ {isNative ? (
+
+ {
+ setIsNativeFocused(true);
+ setHasTypedSinceFocus(false);
+ }}
+ onBlur={handleNativeBlur}
+ onKeyDown={e => {
+ // Only an edit hands the field back to the engine. Tab and
+ // Escape leave the segments untouched, and counting them would
+ // flash the engine's own format over ours for the frame before
+ // focus goes — most visibly on the way out of the field.
+ if (e.key !== 'Tab' && e.key !== 'Escape') {
+ setHasTypedSinceFocus(true);
+ }
+ }}
+ min={min}
+ max={max}
+ // With a disabledMessage the input keeps focusability via
+ // aria-disabled so the reason is focus-discoverable; the mutation
+ // guard in handleNativeChange blocks the picker's result, and
+ // readOnly stops the engine from opening it at all.
+ disabled={isEffectivelyDisabled && !showsDisabledMessage}
+ aria-disabled={showsDisabledMessage ? 'true' : undefined}
+ readOnly={showsDisabledMessage || undefined}
+ aria-labelledby={ariaLabelledBy}
+ aria-describedby={ariaDescribedBy}
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
+ aria-invalid={
+ status?.type === 'error' || !isInputValid ? 'true' : undefined
+ }
+ aria-busy={isBusy || undefined}
+ {...stylex.props(
+ styles.input,
+ styles.nativeInput,
+ showsNativeOverlay && styles.nativeInputTextHidden,
+ isEffectivelyDisabled && styles.inputDisabled,
+ !isInputValid && styles.inputInvalid,
+ )}
+ />
+ {showsNativeOverlay && (
+
+ {nativeOverlayText}
+
+ )}
+
+ ) : (
+
+ )}
{/*
Live region announcing invalid typed input to assistive technology.
The value silently reverts on blur, so without this a screen-reader
@@ -772,20 +1200,21 @@ export function DateInput({
)}
{isBusy && }
{statusIcon}
- {popover.render(
- ,
- {placement: 'below', alignment: 'start'},
- )}
+ {!isNative &&
+ popover.render(
+ ,
+ {placement: 'below', alignment: 'start'},
+ )}
{showsDisabledMessage &&
disabledMessageTooltip.renderTooltip(disabledMessage)}
diff --git a/packages/core/src/DateInput/DateInputNative.test.tsx b/packages/core/src/DateInput/DateInputNative.test.tsx
new file mode 100644
index 000000000000..334f7f4167a6
--- /dev/null
+++ b/packages/core/src/DateInput/DateInputNative.test.tsx
@@ -0,0 +1,792 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file DateInputNative.test.tsx
+ * @input Uses vitest, @testing-library/react, DateInput, useNativeDatePicker
+ * @output Unit tests for DateInput's native (browser/OS) date picker mode
+ * @position Testing; validates the `nativePicker` prop and the coarse-pointer
+ * switch in useNativeDatePicker.ts
+ *
+ * Kept out of DateInput.test.tsx because every test here replaces the global
+ * matchMedia to claim a touch device; the main suite asserts the pointer-based
+ * default (the Calendar popover) and must keep the setup polyfill.
+ *
+ * SYNC: When DateInput.tsx or useNativeDatePicker.ts changes, update tests to
+ * match new behavior
+ */
+
+import {describe, it, expect, vi, afterEach} from 'vitest';
+import {render, screen, fireEvent} from '@testing-library/react';
+import {getButton, queryButton} from '../__tests__/fastRoleQueries';
+import {DateInput} from './DateInput';
+import {supportsNativeDateInput} from './useNativeDatePicker';
+
+/**
+ * Point `(pointer: coarse)` at a touch or mouse device, keeping the setup
+ * polyfill's `(hover: hover) === true` answer for every other query so
+ * hover-gated behavior elsewhere in the tree stays alive.
+ */
+function stubPointer(isCoarse: boolean) {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: /pointer:\s*coarse/.test(query)
+ ? isCoarse
+ : /hover:\s*hover/.test(query),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }));
+}
+
+/** The field itself — role varies by mode, so query the tag. */
+function getInput(): HTMLInputElement {
+ const input = document.querySelector('input');
+ if (!input) {
+ throw new Error('DateInput rendered no input element');
+ }
+ return input;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('DateInput native picker', () => {
+ // ===========================================================================
+ // Which control renders
+ // ===========================================================================
+
+ it('renders a native date control on a touch device', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ const input = getInput();
+ expect(input).toHaveAttribute('type', 'date');
+ // The native control is not a combobox — it owns its own picker, so the
+ // popup-expansion ARIA of the text variant would be a lie.
+ expect(input).not.toHaveAttribute('role', 'combobox');
+ expect(input).not.toHaveAttribute('aria-expanded');
+ expect(input).not.toHaveAttribute('aria-haspopup');
+ });
+
+ it('renders the text field with a calendar popover on a mouse device', () => {
+ stubPointer(false);
+ render( {}} />);
+
+ const input = getInput();
+ expect(input).toHaveAttribute('type', 'text');
+ expect(input).toHaveAttribute('role', 'combobox');
+ });
+
+ it('keeps the calendar popover on touch when nativePicker is "never"', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ expect(getInput()).toHaveAttribute('type', 'text');
+ });
+
+ it('treats an explicit nativePicker="touch" as the default', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ expect(getInput()).toHaveAttribute('type', 'date');
+ });
+
+ it('uses the native control on a mouse device when nativePicker is "always"', () => {
+ stubPointer(false);
+ render(
+ {}} />,
+ );
+
+ expect(getInput()).toHaveAttribute('type', 'date');
+ });
+
+ it('keeps the calendar popover on touch when dateConstraints are set', () => {
+ // A native picker can only express a contiguous min/max range, so an
+ // arbitrary per-date predicate has to stay with the Calendar popover.
+ stubPointer(true);
+ render(
+ date.getDay() !== 0]}
+ onChange={() => {}}
+ />,
+ );
+
+ expect(getInput()).toHaveAttribute('type', 'text');
+ });
+
+ it('never renders the calendar popover in native mode', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ fireEvent.click(getButton('Open calendar'));
+
+ expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
+ // The month grid the popover would mount is absent entirely.
+ expect(queryButton('Next month')).toBeNull();
+ });
+
+ // ===========================================================================
+ // Value round-trip
+ // ===========================================================================
+
+ it('keeps the control\u2019s own value ISO', () => {
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ // ISO is the only form the control accepts, and what the picker reads
+ // and writes; `format` rides on the overlay instead.
+ expect(getInput()).toHaveValue('2026-01-25');
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+ });
+
+ it('fires onChange with the ISO date the control reports', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(getInput(), {target: {value: '2026-03-21'}});
+
+ expect(onChange).toHaveBeenCalledWith('2026-03-21');
+ });
+
+ it('fires onChange with undefined when the control is emptied', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(getInput(), {target: {value: ''}});
+
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+
+ it('forwards min and max to the native control', () => {
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ const input = getInput();
+ expect(input).toHaveAttribute('min', '2026-01-01');
+ expect(input).toHaveAttribute('max', '2026-12-31');
+ });
+
+ it('refuses a constrained date and announces the rejection', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render(
+ date.getDay() !== 0]}
+ onChange={onChange}
+ />,
+ );
+
+ fireEvent.change(getInput(), {target: {value: '2026-03-22'}});
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByRole('alert')).toHaveTextContent('Invalid date');
+ expect(getInput()).toHaveAttribute('aria-invalid', 'true');
+ });
+
+ it('clears the rejection once an allowed date arrives', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render(
+ date.getDay() !== 0]}
+ onChange={onChange}
+ />,
+ );
+
+ const input = getInput();
+ fireEvent.change(input, {target: {value: '2026-03-22'}});
+ fireEvent.change(input, {target: {value: '2026-03-23'}});
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('2026-03-23');
+ expect(screen.getByRole('alert')).toHaveTextContent('');
+ expect(input).not.toHaveAttribute('aria-invalid');
+ });
+
+ it('commits a value the engine changed without firing an event', () => {
+ // iOS changes a date field's value from inside its picker sheet without
+ // always firing an event React observes — its Clear is the case that bit
+ // us. React's next render then writes its own stale value back over the
+ // engine's, so the clear looks like it did nothing and the old date
+ // returns. Assigning `.value` directly is that same silent mutation: it
+ // updates React's internal value tracker, so no synthetic change fires.
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ const input = getInput();
+ input.value = '';
+ expect(onChange).not.toHaveBeenCalled();
+
+ // Closing the picker blurs the control, which is where we reconcile.
+ fireEvent.blur(input);
+
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+
+ it('commits a date the engine set silently', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ const input = getInput();
+ input.value = '2026-12-25';
+ fireEvent.blur(input);
+
+ expect(onChange).toHaveBeenCalledWith('2026-12-25');
+ });
+
+ it('stays quiet on blur when nothing changed', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.blur(getInput());
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('commits an edit React\u2019s synthetic change does not see', () => {
+ // The iOS failure, reproduced: the picker edits the field and a native
+ // `input` event fires, but React's synthetic `onChange` never runs — so
+ // React re-renders and writes its stale value back over the picker's.
+ // Assigning `.value` first updates React's internal value tracker, which
+ // is what makes React skip the synthetic event; the native listener still
+ // sees the real one.
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ const input = getInput();
+ input.value = '2026-03-09';
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('2026-03-09');
+ });
+
+ it('fires one change when both commit paths see the same edit', () => {
+ // React's synthetic change and the native listener both observe an
+ // ordinary edit. An async `changeAction` must not run twice for it.
+ stubPointer(true);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(getInput(), {target: {value: '2026-03-09'}});
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('2026-03-09');
+ });
+
+ it('does not write to the control while it has focus', () => {
+ // The iOS bug this guards: while the picker sheet is open, ANY
+ // programmatic write to the field detaches the sheet from it, and the
+ // user's Reset (or pick) silently stops reaching the input. So an
+ // external value change must wait until the field is no longer focused.
+ stubPointer(true);
+ const {rerender} = render(
+ {}} />,
+ );
+ const input = getInput();
+
+ fireEvent.focus(input);
+ rerender( {}} />);
+
+ expect(input).toHaveValue('2026-03-21');
+ });
+
+ it('applies an external value once the control loses focus', () => {
+ stubPointer(true);
+ const {rerender} = render(
+ {}} />,
+ );
+ const input = getInput();
+
+ fireEvent.focus(input);
+ rerender( {}} />);
+ fireEvent.blur(input);
+
+ expect(input).toHaveValue('2026-12-25');
+ });
+
+ it('applies an external value while unfocused', () => {
+ stubPointer(true);
+ const {rerender} = render(
+ {}} />,
+ );
+
+ rerender( {}} />);
+
+ expect(getInput()).toHaveValue('2026-12-25');
+ });
+
+ it('renders the formatted value over the native control', () => {
+ // The control's own value stays ISO — that is all it accepts — while
+ // `format` rides on the overlay, so a phone reads the same as a desktop.
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ expect(getInput()).toHaveValue('2026-01-25');
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+ });
+
+ it('honours every named format on the native control', () => {
+ stubPointer(true);
+ const {rerender} = render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('Jan 25, 2026')).toBeInTheDocument();
+
+ rerender(
+ {}}
+ />,
+ );
+ expect(screen.getByText('Sun, Jan 25, 2026')).toBeInTheDocument();
+ });
+
+ it('honours a function format on the native control', () => {
+ stubPointer(true);
+ render(
+ `ships ${iso}`}
+ onChange={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('ships 2026-01-25')).toBeInTheDocument();
+ });
+
+ it('swaps the overlay text when a new date is committed', () => {
+ stubPointer(true);
+ const {rerender} = render(
+ {}} />,
+ );
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+
+ rerender( {}} />);
+
+ expect(screen.getByText('December 25, 2026')).toBeInTheDocument();
+ expect(screen.queryByText('January 25, 2026')).toBeNull();
+ });
+
+ it('keeps painting the value while a picker-only control has focus', () => {
+ // The iOS wheel has no segments to reveal, so `format` holds even with
+ // the picker open.
+ stubPointer(true);
+ render( {}} />);
+
+ fireEvent.focus(getInput());
+
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+ });
+
+ it('hands the value back to a segment-editable control on focus', () => {
+ stubPointer(false);
+ render(
+ {}}
+ />,
+ );
+ const input = getInput();
+
+ fireEvent.focus(input);
+ expect(screen.queryByText('January 25, 2026')).toBeNull();
+
+ fireEvent.blur(input);
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+ });
+
+ it('hands the value back after a keystroke even on a coarse pointer', () => {
+ // The backstop: a Windows tablet reports a coarse pointer while desktop
+ // Chrome still renders editable segments. Typing proves segments exist,
+ // whatever the pointer said. A picker-only control never gets a keydown.
+ stubPointer(true);
+ render( {}} />);
+ const input = getInput();
+
+ fireEvent.focus(input);
+ expect(screen.getByText('January 25, 2026')).toBeInTheDocument();
+
+ fireEvent.keyDown(input, {key: '3'});
+
+ expect(screen.queryByText('January 25, 2026')).toBeNull();
+ });
+
+ it('keeps the value overlay out of the accessibility tree', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ expect(screen.getByText('January 25, 2026')).toHaveAttribute(
+ 'aria-hidden',
+ 'true',
+ );
+ });
+
+ it('bounds the overlay so a long date cannot paint past it', () => {
+ // The overlay is absolutely positioned, so without an end inset it is
+ // shrink-to-fit and a long formatted date runs out of the slot and over
+ // whatever follows in the field — measured 24px across the clear button
+ // on an iPhone. Both insets keep it inside, and `textOverflow` trims.
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ const overlay = screen.getByText('September 30, 2026');
+ const classes = new Set(overlay.className.split(/\s+/).filter(Boolean));
+ const rules = Array.from(document.styleSheets)
+ .flatMap(sheet => {
+ try {
+ return Array.from(sheet.cssRules);
+ } catch {
+ return [];
+ }
+ })
+ .map(rule => rule.cssText)
+ .filter(text => [...classes].some(cls => text.includes(`.${cls}`)))
+ .join(' ');
+
+ expect(rules).toContain('inset-inline-end: 0');
+ expect(rules).toContain('text-overflow: ellipsis');
+ // `text-overflow` only applies to a BLOCK container: on a flex one a
+ // too-long date hard-clips mid-glyph instead (measured identical to
+ // `text-overflow: clip` in WebKit and Chromium). Centring then comes
+ // from the line box, so the overlay must carry the input's own leading
+ // or the text sits ~2.4px high.
+ expect(rules).toContain('display: block');
+ expect(rules).toContain('line-height: var(--text-body-leading)');
+ });
+
+ it('drops the rejection once the field reverts', () => {
+ // The refused date is reverted the moment focus leaves, so the field is
+ // showing a valid date again. Marking that date invalid — greyed, with
+ // `aria-invalid` and a standing "Invalid date" announcement — would be a
+ // lie about data the user never chose, and nothing but another edit
+ // would clear it.
+ stubPointer(true);
+ render(
+ date.getDay() !== 0]}
+ onChange={() => {}}
+ />,
+ );
+
+ const input = getInput();
+ fireEvent.focus(input);
+ fireEvent.change(input, {target: {value: '2026-03-22'}}); // a Sunday
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+
+ fireEvent.blur(input);
+
+ expect(input).toHaveValue('2026-03-23');
+ expect(input).not.toHaveAttribute('aria-invalid');
+ expect(screen.getByRole('alert')).toHaveTextContent('');
+ });
+
+ // ===========================================================================
+ // Placeholder
+ // ===========================================================================
+
+ it('shows the placeholder over an empty native control', () => {
+ // The native control has no placeholder of its own — it renders an OS
+ // hint (mm/dd/yyyy). DateInput's placeholder takes that space instead.
+ stubPointer(true);
+ render( {}} />);
+
+ expect(screen.getByText('Select a date')).toBeInTheDocument();
+ });
+
+ it('shows a custom placeholder', () => {
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText('When does it ship?')).toBeInTheDocument();
+ });
+
+ it('hides the placeholder once a date is set', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ expect(screen.queryByText('Select a date')).toBeNull();
+ });
+
+ it('brings the placeholder back when the value is cleared', () => {
+ stubPointer(true);
+ const {rerender} = render(
+ {}} />,
+ );
+ expect(screen.queryByText('Select a date')).toBeNull();
+
+ rerender( {}} />);
+
+ expect(screen.getByText('Select a date')).toBeInTheDocument();
+ });
+
+ it('holds the placeholder while a picker-only control has focus', () => {
+ // On iOS focus IS the picker opening, and an empty focused date control
+ // renders only a single space (WebKit's updateInnerTextValue keeps a
+ // baseline). There is nothing to yield to, so our placeholder stays.
+ stubPointer(true);
+ render( {}} />);
+ const input = getInput();
+
+ fireEvent.focus(input);
+ expect(screen.getByText('Select a date')).toBeInTheDocument();
+
+ fireEvent.blur(input);
+ expect(screen.getByText('Select a date')).toBeInTheDocument();
+ });
+
+ it('yields the placeholder to a segment-editable control on focus', () => {
+ // A fine-pointer date control is a row of typeable segments; once it has
+ // focus the engine owns that text and must be able to show it.
+ stubPointer(false);
+ render(
+ {}} />,
+ );
+ const input = getInput();
+
+ fireEvent.focus(input);
+ expect(screen.queryByText('Select a date')).toBeNull();
+
+ fireEvent.blur(input);
+ expect(screen.getByText('Select a date')).toBeInTheDocument();
+ });
+
+ it('keeps the placeholder out of the accessibility tree', () => {
+ // The field is already named by its label; announcing the placeholder too
+ // would just double-speak.
+ stubPointer(true);
+ render( {}} />);
+
+ expect(screen.getByText('Select a date')).toHaveAttribute(
+ 'aria-hidden',
+ 'true',
+ );
+ });
+
+ // ===========================================================================
+ // Toggle button, clear button, disabled state
+ // ===========================================================================
+
+ it('asks the browser for its picker from the toggle button', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ const input = getInput();
+ const showPicker = vi.fn();
+ // jsdom implements no picker; attach one so the call is observable.
+ (input as HTMLInputElement & {showPicker: () => void}).showPicker =
+ showPicker;
+
+ fireEvent.click(getButton('Open calendar'));
+
+ expect(showPicker).toHaveBeenCalledTimes(1);
+ expect(input).toHaveFocus();
+ });
+
+ it('survives a browser that refuses showPicker', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ const input = getInput();
+ // Chrome throws NotAllowedError without transient user activation, and
+ // InvalidStateError inside a cross-origin iframe. Focus is the fallback.
+ (input as HTMLInputElement & {showPicker: () => void}).showPicker = () => {
+ throw new DOMException('not allowed', 'NotAllowedError');
+ };
+
+ expect(() => fireEvent.click(getButton('Open calendar'))).not.toThrow();
+ expect(input).toHaveFocus();
+ });
+
+ it('leaves the toggle button in its collapsed label', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ fireEvent.click(getButton('Open calendar'));
+
+ // No in-page popover to expand, so the toggle never flips to "Close".
+ expect(getButton('Open calendar')).toBeInTheDocument();
+ expect(queryButton('Close calendar')).toBeNull();
+ });
+
+ it('clears the value from the clear button', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(getButton('Clear Date'));
+
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+
+ it('does not take focus back after clearing', () => {
+ // Focusing a native date control is what raises the OS picker, so
+ // reclaiming focus would pop the wheel the clear tap just dismissed.
+ stubPointer(true);
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.click(getButton('Clear Date'));
+
+ expect(getInput()).not.toHaveFocus();
+ });
+
+ it('takes focus back after clearing the text field', () => {
+ stubPointer(false);
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.click(getButton('Clear Date'));
+
+ expect(getInput()).toHaveFocus();
+ });
+
+ it('disables the native control and its toggle when isDisabled', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ expect(getInput()).toBeDisabled();
+ expect(getButton('Open calendar')).toBeDisabled();
+ });
+
+ it('ignores a change while disabled', () => {
+ stubPointer(true);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ // With a disabledMessage the field stays focusable via aria-disabled, so
+ // the mutation guard is what has to hold.
+ fireEvent.change(getInput(), {target: {value: '2026-03-21'}});
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('forwards ref to the native control', () => {
+ stubPointer(true);
+ const ref = vi.fn();
+ render( {}} />);
+
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement));
+ expect(ref.mock.calls[0][0]).toHaveAttribute('type', 'date');
+ });
+
+ it('keeps the field labelled and required-marked', () => {
+ stubPointer(true);
+ render( {}} />);
+
+ const input = screen.getByLabelText(/Event date/);
+ expect(input).toHaveAttribute('type', 'date');
+ expect(input).toHaveAttribute('aria-required', 'true');
+ });
+
+ // ===========================================================================
+ // Feature detection
+ // ===========================================================================
+
+ it('treats a browser that ignores type="date" as unsupported', () => {
+ const createElement = document.createElement.bind(document);
+ vi.spyOn(document, 'createElement').mockImplementation(
+ (tagName: string) => {
+ const el = createElement(tagName as 'input');
+ if (tagName === 'input') {
+ // A browser without date support keeps the field a text input, and
+ // a text input holds any value verbatim.
+ Object.defineProperty(el, 'type', {
+ get: () => 'text',
+ configurable: true,
+ });
+ }
+ return el;
+ },
+ );
+
+ expect(supportsNativeDateInput()).toBe(false);
+ });
+});
diff --git a/packages/core/src/DateInput/index.ts b/packages/core/src/DateInput/index.ts
index eed5271fd23f..8144a3e54d71 100644
--- a/packages/core/src/DateInput/index.ts
+++ b/packages/core/src/DateInput/index.ts
@@ -14,6 +14,7 @@ export type {
DateInputProps,
DateInputSize,
DateInputFormat,
+ DateInputNativePicker,
DateInputStatus,
DateInputStatusType,
} from './DateInput';
diff --git a/packages/core/src/DateInput/useNativeDatePicker.ts b/packages/core/src/DateInput/useNativeDatePicker.ts
new file mode 100644
index 000000000000..cb4f5b7e42d0
--- /dev/null
+++ b/packages/core/src/DateInput/useNativeDatePicker.ts
@@ -0,0 +1,109 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useNativeDatePicker.ts
+ * @input Uses useMediaQuery
+ * @output Exports DateInputNativePicker, NativeDatePickerState,
+ * useNativeDatePicker, supportsNativeDateInput
+ * @position Internal helper for DateInput; consumed by DateInput.tsx and
+ * tested through DateInputNative.test.tsx
+ *
+ * Decides whether DateInput renders `` (so the browser/OS
+ * shows its own date picker — the iOS wheel, the Android calendar dialog)
+ * instead of the text field plus Calendar popover.
+ *
+ * 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)
+ */
+
+import {useMediaQuery} from '../hooks/useMediaQuery';
+
+/**
+ * When DateInput hands date picking to the browser/OS instead of its own
+ * Calendar popover.
+ *
+ * - `'touch'`: native on touch devices (coarse pointer), the Calendar popover
+ * everywhere else
+ * - `'always'`: native wherever the browser supports ``
+ * - `'never'`: always the Calendar popover
+ */
+export type DateInputNativePicker = 'touch' | 'always' | 'never';
+
+/**
+ * Touch/stylus devices. Matches the media query the inputs already use to
+ * bump their font size and hit areas, so the "is this a touch device?"
+ * answer stays the same one in CSS and in JS.
+ */
+const COARSE_POINTER = '(pointer: coarse)';
+
+/**
+ * Feature-detects ``.
+ *
+ * A browser without date support falls back to `type="text"`, which keeps any
+ * value verbatim; a browser with it runs the value sanitization algorithm and
+ * blanks a value that is not a valid date string.
+ *
+ * @internal Exported for tests.
+ */
+export function supportsNativeDateInput(): boolean {
+ if (typeof document === 'undefined') {
+ return false;
+ }
+ const probe = document.createElement('input');
+ probe.setAttribute('type', 'date');
+ probe.value = 'not-a-date';
+ return probe.type === 'date' && probe.value === '';
+}
+
+/**
+ * Whether DateInput should render the native date control, and whether that
+ * control lets the user edit its date segments.
+ */
+export interface NativeDatePickerState {
+ /** Render `` instead of the text field + Calendar. */
+ isNative: boolean;
+ /**
+ * Whether the engine lets the user type into the control's segments.
+ *
+ * A fine-pointer date control is a row of individually editable fields; the
+ * iOS wheel and the Android dialog are picker-only, and their field is a
+ * single text run that is never typed into. WebKit builds the segmented
+ * `DateTimeEditElement` only under `PLATFORM(MAC) || PLATFORM(GTK)`, and
+ * Blink's `InputMultipleFieldsUI` is off on Android and iOS — so the
+ * pointer is a good, not perfect, proxy. DateInput keeps a keydown backstop
+ * for the hybrid devices it gets wrong (a Windows tablet reports a coarse
+ * pointer while desktop Chrome still renders editable segments).
+ */
+ isSegmentEditable: boolean;
+}
+
+/**
+ * Whether DateInput should render the native date control.
+ *
+ * SSR-safe: `useMediaQuery` reports `false` on the server and during
+ * hydration, so the server always renders the text field and the swap (if
+ * any) happens on the client's first committed render.
+ *
+ * `dateConstraints` forces the Calendar popover in `'touch'` mode: the native
+ * control can only express a contiguous `min`/`max` range, so an arbitrary
+ * per-date predicate would let the user pick a date the field then refuses.
+ * `'always'` still goes native and rejects a constrained date on commit.
+ */
+export function useNativeDatePicker(
+ mode: DateInputNativePicker,
+ hasDateConstraints: boolean,
+): NativeDatePickerState {
+ const isTouch = useMediaQuery(COARSE_POINTER);
+
+ const isNative =
+ mode === 'never'
+ ? false
+ : mode === 'always'
+ ? supportsNativeDateInput()
+ : isTouch && !hasDateConstraints && supportsNativeDateInput();
+
+ return {isNative, isSegmentEditable: isNative && !isTouch};
+}