Skip to content

feat(DateInput): show the browser/OS date picker on touch devices - #5261

Open
imdreamrunner wants to merge 1 commit into
mainfrom
feat/date-input-native-touch
Open

feat(DateInput): show the browser/OS date picker on touch devices#5261
imdreamrunner wants to merge 1 commit into
mainfrom
feat/date-input-native-touch

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Problem

On a phone, DateInput's calendar toggle opens a 320px month grid in a popover — a desktop control shrunk onto a screen whose platform already ships a date picker every user knows. The native one comes with system-sized hit areas, momentum scrolling, and the OS locale, calendar, and accessibility settings applied for free; ours arrives with mouse-sized day cells and a text field that invites a keyboard.

Change

DateInput renders <input type="date"> when the pointer is coarse, so iOS raises its wheel and Android its calendar dialog. The calendar toggle button asks the engine for that picker (showPicker(), falling back to focus on engines without it), and the Calendar popover is not mounted at all in that mode.

New prop, nativePicker — every value names when the native picker appears:

Value Behavior
'touch' (default) native control on touch devices (coarse pointer), Calendar popover on mouse-driven ones
'always' native wherever the browser supports <input type="date">
'never' Calendar popover everywhere, phones included
// Default — phones get the OS picker, desktops keep the calendar
<DateInput label="Event date" value={date} onChange={setDate} />

// A field that must look identical on every device
<DateInput label="Event date" value={date} onChange={setDate} nativePicker="never" />

Behavior notes

  • SSR-safe. The decision runs through useMediaQuery (useSyncExternalStore), so the server always renders the text field and the swap happens on the client's first committed render. A browser without type="date" support (feature-detected via value sanitization) keeps the text field too.
  • The browser owns the field in native mode, so format, numberOfMonths, and weekStartsOn no longer apply — the control shows the OS locale's date format.
  • placeholder still shows. A native date control has none of its own, just an OS mm/dd/yyyy hint, so an empty field lost the placeholder the text variant shows. That hint is now hidden while the field is empty and unfocused — a transparent text color, which covers Chromium's ::-webkit-datetime-edit and Firefox's plain text alike — and the placeholder is rendered over that space at the text variant's exact position and token. Focus restores the hint so the segment being scrolled or typed is always visible; the overlay is aria-hidden (the field already has a label) and pointer-transparent, so a tap on it still reaches the control and raises the picker.
  • The native control is made to sit like the text field it replaces. 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 for older WebKit) with appearance: none to drop iOS's button-like chrome, Chromium's duplicate calendar glyph hidden, and WebKit's centered value and inner spacing reset.
  • 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 — which reads as the clear doing nothing. The text field still restores the caret.
  • min/max carry over to the native control. dateConstraints cannot — a native picker only expresses a contiguous range — so 'touch' keeps the Calendar popover whenever constraints are set, rather than letting a user pick a date the field then refuses. 'always' still goes native, refusing a constrained date on commit and announcing it through the existing live region.
  • ARIA follows the control. The native field drops role="combobox", aria-expanded, aria-haspopup, and aria-controls — there is no in-page popup to describe — and keeps the label, description, status, required, and busy wiring. The toggle keeps its "Open calendar" name and never flips to "Close".
  • Everything else is unchanged: clear button, status icons, spinner/optimistic changeAction, disabledMessage, InputGroup rendering, and the whole desktop path.

Test plan

  • New DateInputNative.test.tsx — 31 tests over the coarse-pointer switch, each nativePicker value, the dateConstraints fallback and rejection, ISO value round-trip, min/max forwarding, showPicker (including an engine that throws), the placeholder (default, custom, hidden when filled, restored on clear, yielded on focus, aria-hidden), clear + its focus behavior in both modes, disabled, ref forwarding, labelling, and the feature detection.
  • DateInput.test.tsx's 94 existing tests pass untouched — the setup polyfill reports a fine pointer, so the desktop path is the default under test.
  • pnpm lint:strict, pnpm test, pnpm build all green (56 pre-existing warnings, 0 errors).
  • Verified in Chromium under devices['iPhone 15'] and devices['Pixel 7']: the native field's box is identical to the nativePicker="never" field's (22.84px tall, same top and left inset, centered in the same row); the placeholder overlay lands at delta 0 horizontally and vertically against where the value renders, in the same rgb(78, 96, 111) the text variant's ::placeholder uses, and a tap at its coordinates focuses the control underneath (Playwright reports the input "intercepts pointer events", which is the overlay being correctly transparent); the toggle calls showPicker() and focuses the control; picking a date drives the controlled value and drops the placeholder; clearing empties the field, restores the placeholder, and does not re-raise the picker. Desktop context keeps the text combobox.
  • Also loaded on a real iPhone against a Storybook build of this branch.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 20, 2026
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 21, 2026 1:27am

Request Review

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

DateInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 719 -
Complexity N/A Very High (120) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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 `<input type="date">` 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. In native mode the browser owns the field, so format, numberOfMonths,
and weekStartsOn stop applying; min and max carry over, 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).

Three things the native control needs to sit like the text field it replaces.

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 now pinned to one line box
(`1lh`, with a calc() fallback) with `appearance: none` to drop iOS's
button-like chrome.

A native date control has no placeholder — it renders its own mm/dd/yyyy
hint — so an empty field lost the one the text variant shows. The hint is
now hidden while the field is empty and unfocused (transparent text color,
which covers Chromium's ::-webkit-datetime-edit and Firefox's plain text
alike) and DateInput's placeholder is rendered over that space, matching the
text variant's position and token. Focus restores the hint, so the segment
being scrolled or typed is always visible; the overlay is aria-hidden and
pointer-transparent, so it neither double-speaks over the label nor
intercepts the tap that raises the picker.

And clearing no longer takes focus back in native mode: focusing a date
control is what raises the OS picker, so the old focus restore popped the
picker the clear tap had just dismissed, which read as the clear doing
nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant