From 65321bbaa33e521b8cacc8b04045d563579d7418 Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Thu, 20 Aug 2026 22:02:09 -0700 Subject: [PATCH] feat(DateInput): show the browser/OS date picker on touch devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone, tapping the calendar toggle opened a 320px month grid in a popover — a desktop control shrunk onto a screen whose platform already ships a date picker every user knows, with system hit areas, momentum scrolling, and the OS locale and accessibility settings applied. DateInput now renders `` when the pointer is coarse, so iOS raises its wheel and Android its calendar dialog, and the toggle button asks the engine for that picker (`showPicker()`, falling back to focus for engines without it). The new `nativePicker` prop takes 'touch' (the default), 'always', or 'never' — every value naming when the native picker appears. The decision is client-side (`useMediaQuery`, SSR-safe), so the server keeps rendering the text field; a browser without `type="date"` support keeps it too. min and max carry over to the native control, and dateConstraints — which a contiguous native range cannot express — keeps 'touch' on the Calendar popover ('always' refuses a constrained date on commit and announces it). DateInput paints the closed field's text itself, so `format` and `placeholder` keep applying: the control's own text is made transparent and ours is drawn over it, pointer-transparent so the tap still raises the picker. The value the control holds stays ISO — all it accepts, and what the picker reads and writes. The engine owns that text only while the user is editing date segments: a picker-only control (the iOS wheel, the Android dialog) has none, so our text holds even with the picker open and tracks the wheel live, while a segment-editable desktop control hands its text back on focus. A keydown does the same, as a backstop for a device whose coarse pointer misreports it. `numberOfMonths` and `weekStartsOn` still do not apply — they describe a calendar grid the native picker does not have. Three things the native control needs to behave like the text field it replaces, each measured on a real iPhone rather than inferred. Its intrinsic height comes from its inner edit fields rather than line-height, so it rendered ~2px taller and its value sat off the text variant's baseline in the same flex row; it is pinned to one line box (`1lh`, with a calc() fallback) with `appearance: none` to drop iOS's button-like chrome. The element is uncontrolled, with a value React never rewrites after mount. While the iOS picker sheet is open, ANY programmatic write to the field — React re-rendering a controlled `value`, or syncing a changed `defaultValue` — detaches the sheet from it: the wheel and Reset keep moving the sheet's own highlight, but nothing reaches the input and no event fires, so Reset appeared to do nothing. A plain input carrying the same CSS has no such problem, which is what ruled the styling out. External changes are pushed in by an effect that only writes while the field is unfocused, and edits are read back through native listeners, because React's synthetic change does not reliably observe this control. And clearing does not take focus back in native mode: focusing a date control is what raises the OS picker, so the usual focus restore popped the picker the clear tap had just dismissed. --- .changeset/date-input-native-touch-picker.md | 30 + apps/storybook/stories/DateInput.stories.tsx | 54 ++ packages/core/src/DateInput/DateInput.doc.mjs | 23 +- packages/core/src/DateInput/DateInput.tsx | 555 ++++++++++-- .../src/DateInput/DateInputNative.test.tsx | 792 ++++++++++++++++++ packages/core/src/DateInput/index.ts | 1 + .../core/src/DateInput/useNativeDatePicker.ts | 109 +++ 7 files changed, 1498 insertions(+), 66 deletions(-) create mode 100644 .changeset/date-input-native-touch-picker.md create mode 100644 packages/core/src/DateInput/DateInputNative.test.tsx create mode 100644 packages/core/src/DateInput/useNativeDatePicker.ts 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 && ( + + )} + + ) : ( + + )} {/* 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}; +}