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}; +}