Skip to content

feat(core)!: DateInput fits the pointer — a touch picker on a finger, the text field on a mouse - #5243

Merged
imdreamrunner merged 30 commits into
mainfrom
feat/date-input-mobile
Aug 22, 2026
Merged

feat(core)!: DateInput fits the pointer — a touch picker on a finger, the text field on a mouse#5243
imdreamrunner merged 30 commits into
mainfrom
feat/date-input-mobile

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What this does

DateInput has always been a control for a mouse: a field you type into with a
calendar in a popover beside it. On a phone or a tablet that is the wrong
shape. The popover is a desktop calendar operated by thumb, and focusing the
field summons a keyboard that covers the thing it is meant to fill in.

The same component now renders a second surface where the primary pointer is a
finger — a bottom sheet holding one month per screen, swiped sideways, with
month and year wheels behind the header title for the far jumps swiping is bad
at.

Nothing changes at the call site. DateInputProps is byte-identical — same
25 props, none added, removed or renamed. One component with two surfaces, not
two components: no new import, no media query to write, no decision pushed onto
consumers. A date typed on a laptop and a date thumbed on a phone are the same
value.

The pointer surface is untouched

The whole diff to DateInput.tsx is two imports, a rename of the existing
implementation to PointerDateField, and the switch:

export function DateInput(props: DateInputProps) {
  const isTouch = useMediaQuery(TOUCH_POINTER_QUERY);
  return isTouch ? <TouchDateField {...props} /> : <PointerDateField {...props} />;
}

The old function body is unchanged. Two components rather than one with a
branch inside, because their hook lists differ.

Why the pointer, and only the pointer

The switch is (pointer: coarse) with no width bound. pointer reports
the PRIMARY device, which is what makes it the whole test — measured across
device profiles:

reports surface
iPhone 15 coarse touch
iPad Pro 11 landscape (1194px) coarse touch
touchscreen laptop (1366px) fine + any-pointer: coarse pointer
desktop, window narrowed to 500px fine pointer

A touchscreen laptop keeps the typable field because its keyboard is right
there. A narrowed desktop window is still a mouse. Adding a width test would
only re-exclude tablets — the clearest case for a thumb picker there is.

The picker

  • One month per screen, snapped, swiped sideways. Every angle from 0° to
    62° pages exactly one month; 65°+ still dismisses the sheet. Getting there
    needed a fallback: touch-action: pan-x stops panning at 45°, so a claimed
    gesture past that did nothing at all until the scroller learned to page
    itself when the browser declines.
  • Arrows in the header's trailing corner for a single step, hidden while
    the wheels are up since they step a calendar that is not on screen.
  • Month and year wheels behind the title, which answer a mouse as well as a
    finger.
  • A tap commits immediately and leaves the sheet up, so a mistake is
    corrected in place. No footer button is ever the commit: Save closes the
    picker, Reset puts it back to how it opened (no date, current month), and
    Done only leaves the wheels for the calendar.
  • The grid matches the desktop calendar's: adjacent-month days spill in
    muted and unselectable, weekday headers are three letters, and the day tiers
    are the desktop's own — 23 in-month, 185 out of range, 203 spilled, flattened
    on white. Measured on both surfaces of the same story rather than read off
    the source: the two now render the same three tiers at identical colour and
    opacity. A spilled day is context, not a choice — a pane here IS a month, so
    tapping April 1 out of March's pane would move the calendar out from under
    the thumb that tapped it.
  • The wheels fade in and out as one layer, over a calendar that stays
    still.
    The layer carries an opaque background of its own, which is what
    makes the fade uniform: it renders as a finished image and the fade applies
    to the image, instead of the translucent selection band compositing against
    a live grid on its own terms and crossing at a different rate from the text
    beside it — the original "the grey area animates differently". The calendar
    never fades; it is covered and uncovered. Measured on an iPhone profile with
    the animation clock at 2%, both directions: the calendar's opacity is 1 on
    every frame and it goes hidden only once the wheels reach 1.00, returning
    on the first frame of the way back.
  • Every target floored at 44px on a coarse pointer.

What this adds to the public surface

Almost nothing, on purpose:

  • TOUCH_POINTER_QUERY — the query the switch uses, so a consumer can ask the
    same question and lay out to match.
  • Six @astryx.dateInput.* catalog keys for the picker's header and footer.

That is the whole list. No new propsDateInputProps is byte-identical
at 25. Three things that were heading for the public surface were pulled back
out before this went up for review:

Considered Shipped as Why
--date-input-touch-day-size, --date-input-touch-wheel-item-size theme vars compile-time constants The day size is the 44px accessibility floor every target in the sheet is held to; a variable a theme can quietly lower is not a floor. The wheel row only makes sense against it, so neither is independently tunable.
astryx-date-input-touch-title theme target a data- attribute Internal structure of the sheet. The field and its toggle icon are the documented targets and already existed.
an export that forces the touch surface (nothing) Existed only for the Storybook stories; when those went it had no consumer.

Each is additive later and awkward to withdraw once someone depends on it,
which is the asymmetry worth respecting while the component is new.

A consequence worth stating: because the theming contract tests only require a
doc entry for surface that actually exists, DateInput.doc.mjs and
theme/derivedVarRegistry.test.ts are now byte-identical to main. The
docsite's DateInput page therefore says nothing about the touch surface. The
behaviour is documented in the source headers and this changeset; a docs pass
belongs in its own change, against a component that has settled.

Reviewing it

Open any Core/DateInput story on a phone or tablet — or in a
device-emulated tab reporting a coarse pointer. That is not a workaround for a
missing story; those 24 stories ARE the touch surface's coverage. "Default",
"Bounded range", "Field states" each render the picker on a touch device and
the text field on a mouse, from the same source, which is precisely the claim
this PR makes.

There is no touch-specific story, deliberately. A story earns its place by
demonstrating something a caller can do — a prop, a composition, a state — and
this change adds no prop and no composition. A story showing the touch surface
next to the others would demonstrate nothing except its own existence, and
would imply there is a separate thing to adopt, which is the message the whole
change exists to remove.

Five such stories did exist while this lived in lab, and none of them worked on
a desktop canvas: the sheet is a viewport-width overlay portalled to the body,
so a story's 360px "phone" frame constrained the field and nothing else — the
sheet spanned the full canvas with a small calendar marooned in it, clipped at
the bottom. Opening one of the field-states story's four fields covered the
other three. They are gone, and Core/DateInput's description now explains the
two surfaces and how to reach the other one.

Cost

+15.7 KB gzipped on DateInput's 63 KB (~24%), for every consumer
including desktop-only ones, since the choice is made at runtime. Lazy-loading
would avoid it but needs a Suspense boundary inside a form control — worth a
decision rather than a silent default.

Bugs found and fixed on the way

  • A click on a wheel row that wobbled more than a pixel selected nothing.
    BottomSheet begins its drag-to-dismiss from a pointerdown on its body and
    captures the pointer, retargeting every later event including the click.
    Predates this PR; verified by reproducing it with the new code disabled.
    Fixed without touching BottomSheet.
  • The wheels ignored the mouse entirely — browsers do not drag-scroll an
    overflow container, so pressing and pulling on the one control shaped like a
    thing you spin did nothing. Dragging works now, mouse only; touch and pen
    keep their native panning.
  • A transform on a wrapper span rather than the Icon it moves.
  • An unguarded IME keydown that would open the sheet on the keystroke
    committing a Korean syllable.

The last two were caught by core's lint rules the moment the component moved
out of lab — which is most of the argument for it living here.

Verification

Ten gates green: no-cache lint, six typechecks, lab:readiness:check, and the
full suite by exit code. 191 tests on DateInput, up from 96. Behaviour that
cannot be asserted in jsdom — gesture angles, drag mechanics, snap fighting,
header shift — was measured in Chromium with CDP touch events, and each new
test was checked against a negative control.

Before merging

Two calls I did not want to make alone:

  • The changeset is patch. This removes DateInputNext and
    MobileDateField from @astryxdesign/lab, which is arguably [breaking].
    I left it patch because lab is 0.x and neither name ever landed on main —
    say the word and it changes.
  • The runtime cost (+15.7 KB gzipped, above) is the one thing here that
    every consumer pays whether or not they ever see a touch device. Worth a
    second opinion before it ships.

A caveat for reviewers

The gesture and animation work is verified in Chromium only — WebKit will
not run on the devserver I built this on. Every iOS-specific fix in here (the
month-drift feedback loop, the scroll-settle guards, the fade sequencing) is
reasoned from documented ways iOS differs — no scrollend below Safari 26,
momentum that outlives the touch — and then confirmed by device testing rather
than by watching it fail and stop failing locally. The unit tests pin the
logic; they cannot pin the platform behaviour that motivated it.

@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 22, 2026 12:24am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 20, 2026
@imdreamrunner imdreamrunner changed the title [WIP] feat(lab): DateInputMobile — touch date picker with continuous, snap-paged months [WIP] feat(lab): DateInputMobile — a drop-in DateInput that picks its own surface Aug 20, 2026
@imdreamrunner imdreamrunner changed the title [WIP] feat(lab): DateInputMobile — a drop-in DateInput that picks its own surface [WIP] feat(lab): DateInputNext — a drop-in DateInput that picks its own surface Aug 20, 2026
@imdreamrunner
imdreamrunner force-pushed the feat/date-input-mobile branch from 03987a0 to 9db67c4 Compare August 20, 2026 18:51
@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 2643 -
Complexity N/A Very High (268) -

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

…heet

Reported as the wheel fighting swipe-to-dismiss. Reproduced with real touch
events, and it was worse than that: a downward drag on the CALENDAR dismissed
the sheet outright, and one on a wheel did nothing at all — the sheet's
preventDefault() killed the wheel's native scroll and gave back a drag too
small to see. Both scrollers were unusable by finger.

## Cause

BottomSheet implements swipe-to-dismiss by watching touches on its scrolling
body: at the body's scroll top, a downward pull stops being a scroll and
becomes a sheet drag. The test is `body.scrollTop` — and the body of a sheet
sized to hug its content never scrolls, so it reads as "at the top" forever.
Every downward drag anywhere inside therefore promotes to a dismiss, however
scrollable the thing under the finger is.

## Fix

A nested scroller claims the gesture that lands on it, so the sheet never sees
it: `useOwnScrollGesture` attaches native `touchstart`/`touchmove` listeners
that call `stopPropagation()`. Applied to the month scroller and to each wheel.

Native listeners, not React handlers: React delegates at the root container,
which is an ANCESTOR of the sheet body, so a React onTouchStart would run after
the body's listener had already claimed the gesture. Nothing calls
preventDefault, so the listeners stay passive and the scrollers keep native
momentum, snapping and rubber-banding.

`touchend`/`touchcancel` deliberately still propagate — they carry no
interpretation, and letting them through is what resets the sheet's
bookkeeping.

## Why not a smarter handoff

The tempting alternative is to release the gesture at a scroller's own extreme,
so pulling down at the top of the calendar still dismisses. It cannot work from
this side: the sheet anchors a promoted drag to the Y where the finger first
touched, so handing it a gesture mid-scroll moves the sheet by everything the
finger already spent scrolling — a jump the length of the swipe. The sheet has
machinery for exactly that (`contentEndY`), but it only runs for its own body,
and reaching it would mean changing BottomSheet — which this fix must not do.

So the scrollers take the whole gesture. Dismissal stays available from the
grab handle and the picker header, which is where a picker sheet on iOS puts it
anyway, and both are verified below.

## Verified

Real touch sequences (CDP) on an iPhone 15 profile, before and after:
- calendar drag down: dismissed the sheet -> scrolls one month, sheet stays
- wheel drag down: did nothing -> spins the wheel, sheet stays
- grab handle drag: still dismisses
- picker header drag: still dismisses
- day tap, wheel-row tap: still commit (stopPropagation must not reach click)
- upward calendar drag: still scrolls forward

Five unit tests pin the contract, and the two that matter fail without the fix.

Unrelated, and left alone: BottomSheet logs ~13 "Ignored attempt to cancel a
touchmove event with cancelable=false" during its own drag. Measured identical
with and without this change.
CI's `test` job failed on this branch while every one of its 11011 tests
passed. The failure was an UNHANDLED ERROR, which vitest exits 1 for and
reports separately from the test summary:

  TypeError: Cannot read properties of undefined (reading '0')
    at onTouchStart (packages/core/src/BottomSheet/useSheetGestures.ts:1231)

My gesture-ownership tests dispatch a bare `new Event('touchstart')`. jsdom has
no constructible TouchEvent, and a bare Event carries no `changedTouches` — so
the one test that deliberately lets an event REACH the sheet crashed inside the
sheet's real listener, which reads `event.changedTouches[0]`.

Not a BottomSheet bug: a browser's touchstart always carries the list. The fake
event was the unrealistic part, so it now carries `changedTouches`, `touches`
and `targetTouches`. The sheet handles it, and the test exercises the sheet
instead of blowing up inside it.

How it reached CI: I checked test runs by grepping the "Tests" summary line,
which said `7506 passed` and stayed silent about `Errors 1 error`. My local
gate script now runs `pnpm test` and trusts its EXIT CODE instead.

Re-verified after the change that the two ownership tests still fail with the
fix disabled — the more realistic event could have made them vacuous, and did
not.
Three changes to how the touch picker is driven, plus tighter wheels.

## The header swapped buttons; now it does not

The header carried Today, and swapped it for a Done while the wheels were up.
That Done read as a commit step the wheels do not have — each wheel commits
when it comes to rest — and the swap made the header's right-hand action mean
two different things depending on a state you might not have noticed.

The header is now just the title. Its rotating chevron already says which
surface is up, and tapping it is what closes the wheels.

## A footer: Today at the start, Done at the end

Navigation on the left, dismissal on the right — the reading order for a sheet
footer, and it keeps the destination-changing action off the thumb's path to
the one that closes. Today still means "go to the current month", which the
wheels do as well as the calendar, so the footer does not change with the
surface.

## Selection no longer dismisses

A tap on a day fires onChange exactly as before — the commit is the tap — but
the sheet now stays up. Picking a date and immediately seeing the sheet vanish
gave no chance to notice a mis-tap, and correcting one meant reopening and
re-finding the month. Now a second tap corrects the first in place.

Done therefore commits nothing: by the time it is reachable, onChange has
already fired. It is a close button, exactly equivalent to the grab handle, the
scrim and Escape — which is why those all stay, and why there is no Cancel to
pair with it. Documented as a don't, because "Done" invites exactly that
reading.

## Wheels: 34px rows, down from 40px

Rows sit closer together and ~8 options are on screen instead of ~6, which is
rather the point of a wheel. Deliberately under a day cell's 44px: a wheel row
is scroll-first — a value is chosen by bringing it under the band, and tapping
one is a shortcut, not the mechanism — so it does not carry a day cell's
tap-target duty, and a mis-tap costs nothing because the neighbours are the
same control and the result is visible immediately. iOS rows are ~32.

The whole wheel geometry derives from that one token, which is what the token
was for: verified in a browser that the band, the end padding (so row 0 can
still centre) and every snap offset followed it, and that a spin still settles
on an exact row.

## Verified

70 unit tests, and in a browser on an iPhone 15 profile: Today bottom-left and
Done bottom-right on one row; a day tap commits and the sheet stays; a second
tap corrects it; Done closes and the value survives.

Also fixed a trap in the test harness: `Controlled` spread a test's `onChange`
over its own state setter, so any test passing a spy silently stopped updating
the field — in exactly the tests watching it most closely. The two are composed
now.
…at md

Today moved the calendar to the current month but did not select it — so the
one thing its name promises is the thing it did not do, and it read as broken.
Navigating and selecting are two different intents and the button conflated
them; either behavior alone is defensible, but it has to pick one on purpose.
Removed until then. Today is still one flick away by scroll, and the marker on
the day is unaffected.

That leaves Done alone in the footer, so it moves to the end (where a sheet's
confirming action belongs, and under the thumb) and grows to `md` — it is the
only action there, not one of a pair of compact ghosts.

Done still commits nothing: a tap on a day has already fired onChange by the
time it is reachable.

Verified in a browser on an iPhone 15 profile: no Today button, Done 32px at
the end, a tap still commits and holds the sheet open, Done still closes and
the value survives. 70 unit tests, all ten gates green.
## Months page along the inline axis

The scroller was vertical; it now swipes left/right, one month per screen,
`scroll-snap-type: x mandatory`. Panes are positioned with `insetInlineStart`
and sized from the measured scrollport WIDTH.

This also settles the gesture fight with the sheet, better than claiming the
gesture did. `touch-action: pan-x` splits the two by axis: horizontal pans stay
with the calendar, vertical ones go straight to the sheet — so a downward drag
on the calendar means swipe-to-dismiss again, which is what a sheet should do.
`useOwnScrollGesture` is therefore gone from MonthScroller and kept on the
wheels, which are still vertical and still conflict.

RTL is the part that needed care: `scrollLeft` counts DOWN from zero when the
inline axis runs right-to-left, so an unsigned read would have pinned an RTL
calendar to month zero forever. `rowAtScrollOffset`/`scrollOffsetForRow` own
that sign in one place, and a test asserts they round-trip in both directions.
The styles use logical properties throughout, with a test that the style block
contains no physical `left`/`right`.

## A chevron at each corner

The only way to change month was a swipe — no affordance, and nothing for a
pointer or a keyboard. Now a chevron sits at each corner with the month and
year centred between them, each a 44px target, mirrored under RTL via the
shared `rtlStyles.mirror`. They step through the same `scrollToMonth` the swipe
settles on, so the two cannot disagree about where a month rests, and they
disable (not hide) at the ends of the range, so the title never shifts.

They navigate only: paging does not touch the selection. That is the
distinction the old Today button got wrong.

Reuses `@astryx.calendar.previousMonth` / `nextMonth`, which already exist for
the desktop Calendar's chevrons.

## Wheels: 28px rows, 17px text

Down from 34px rows at 14px text. The row is now close to the text it holds,
the way a platform picker packs them — the column should read as one run of
values, not a stack of buttons with air between them. ~9 options on screen,
up from ~6 originally. Text goes up because on a wheel the value under the band
is the whole interface.

## Verified

18 browser assertions on an iPhone 15 profile: arrows at the 16px insets with
44px targets; next/prev page exactly one pane and land snapped; a sideways
swipe pages and snaps; a downward drag dismisses the sheet again; wheel rows
28px at 17px with ~9 visible, still snapped exactly on the committed row and
the end padding re-derived. 79 unit tests, all ten gates green.
Three things from review of the sideways-paging picker.

The arrows are `IconButton`s now, in a pair at the header's trailing
corner with the month/year title back on the left where it was. The
glyph was off-centre because the span carrying the RTL mirror was a bare
inline box, so the SVG sat on the text baseline — core's Calendar has
the same wrapper and the `display: inline-flex` that fixes it. They also
floor to 44px on a coarse pointer: Button's own sizes stop at 36px,
under the target every other control in this sheet honours.

The gesture conflict with the sheet is measurably better. An axis lock
replaces the blanket claim, so a downward drag on the calendar is the
sheet's again and swipe-to-dismiss works from the calendar itself, not
just the handle. Then the part that took the measuring: claiming a
gesture the browser will not pan is worse than not claiming it, because
the sheet has been told to keep off and nothing moves at all. An angle
sweep on an iPhone 15 profile found exactly that band — 45° to 60° did
nothing whatsoever, while 0-43° paged and 65°+ dismissed. `onSwipe`
reports a claimed gesture the browser declined (its scroll offset never
moved) so the scroller can page itself, which closes the band without
second-guessing native momentum on the pans that did work. The sweep now
reads: one month per swipe from 0° to 62°, dismiss from 65°.

Horizontal was already the more sensitive axis on distance — a 30px
flick pages a month where the sheet wants ~200px of pull — so the angle
was the whole complaint.
Moves the touch date picker out of lab and into `DateInput` itself, so it is
a surface of the real component rather than a parallel one to adopt.

That is the whole point of the change. A drop-in twin only helps the callers
who hear about it and remember to swap the import; every other `DateInput` in
the codebase keeps handing a phone a popover calendar to operate by thumb.
Making it a surface fixes them all at once, and there is nothing to adopt: no
new import, no media query at the call site, no second component to keep in
step as DateInput grows.

The pointer implementation is untouched. Its function body is byte-identical
— the diff to DateInput.tsx is two imports, a rename to `PointerDateField`,
and the small switch component that chooses. With a mouse the rendered output
is exactly what it was.

The switch is `pointer: coarse` alone, with no width bound. `pointer` means
the PRIMARY device, so a touchscreen laptop reports `fine` and keeps the
typable field, and a narrowed desktop window is still a mouse — measured both.
A width bound would only re-exclude tablets, which are the clearest case for a
thumb picker there is: an iPad Pro reports coarse at 1194px and used to get
the popover.

Three things fell out of the move, all of them arguments for it:

- The core exports the lab version needed are all gone. `groupStyles` was the
  uncomfortable one — positional CSS encoding how InputGroup segments its
  children, which a lab component could only reach by publishing it from a
  patch release. In core it is an ordinary sibling import.
- Core lints harder than lab, and caught two real bugs: a transform on a
  wrapper span instead of the Icon it moves, and an unguarded IME keydown that
  would have opened the sheet on the keystroke committing a Korean syllable.
- Core's theming contract tests made the new surface declare itself — its
  theme target and both CSS variables are now documented, in English and
  Chinese, the way every other core component's are.

Costs 15.7 KB gzipped on top of DateInput's 63 KB, for every consumer
including desktop-only ones, since the choice is made at runtime.

`MobileDateField` and `DateInputNext` are gone from `@astryxdesign/lab`. The
touch surface is reachable as `DateInputTouchSurface` for a story or a
handset-only app, but `DateInput` is the one to use.
… mode

Two things from review of the touch picker on a desktop browser.

**The wheels ignored the mouse.** A wheel is a scroll container, which is what
makes it feel right under a finger — the platform supplies panning, momentum
and the snap. A mouse gets none of that: browsers do not drag-scroll an
overflow container, so pressing and pulling on the one control in the sheet
that is shaped like a thing you spin did nothing whatsoever. That left the
scroll wheel and a click on a visible row, neither of which is the gesture the
control invites. It matters on desktop specifically, because that is where
this surface gets reviewed, themed and screenshotted.

`usePointerDragScroll` adds the drag, for `pointerType === 'mouse'` only —
touch and pen already pan natively, with momentum this cannot match, and are
left alone. Two details the measurements forced:

- Snapping has to be suspended for the duration. With `y mandatory` left on, 7
  of 8 five-pixel drag steps were yanked back to a snap position: the wheel
  sticks to a row, then jumps a whole one. The drag turns snapping off, glides
  to the nearest row on release, and restores it once that settles.
- The press must not reach BottomSheet, which starts its own drag-to-dismiss
  from a `pointerdown` on its body and CAPTURES the pointer for it. This also
  fixes a bug that predates the drag: because capture retargets every later
  pointer event, a click on a wheel row that wobbled more than a pixel or two
  landed on the sheet body and selected nothing. Verified pre-existing by
  disabling the new hook and reproducing it. BottomSheet is untouched — the
  press is claimed by a native listener during the real bubble phase, before
  React's delegated handler at the root can see it, the same mechanism the
  touch conflict already uses.

**The month arrows are hidden while the wheels are up**, since they step a
calendar that is not on screen. Hidden rather than unmounted: on a coarse
pointer they are the tallest thing in the header at 44px against the title's
36, so dropping them would shorten the header and shift the sheet just as the
panels cross-fade. They keep their box, fade with everything else, and go
`inert` so nothing invisible answers a click or a Tab.

Measured on an iPhone 15 profile: header 36px and sheet 659px in both modes,
no shift either way, and touch drag on the wheels unaffected.
…anvas

Five of the six forced the touch surface on whatever canvas Storybook gave
them, and a desktop canvas is the wrong one. The sheet is a viewport-width
overlay portalled to the body, so the story's 360px "phone" frame constrained
the field and nothing else: the sheet spanned the full 1280px with a small
calendar marooned in the middle of it, clipped at the bottom so Done sat on
the edge of the canvas. The field-states story was the worst of them — four
fields to compare, and opening any one of them covered the other three.

There is no phone frame to put them in; the repo has no viewport addon. A
device-emulated tab does the job properly, and so does a real phone, which is
where this surface is worth looking at anyway.

So the whole file goes, and Core/DateInput's own description picks up what it
was there to say: that the component has two surfaces, which one you are
looking at, and how to see the other. Nothing is lost from the docs — the
props, anatomy, theming and guidance all live in DateInput.doc.mjs and were
already updated when the surface moved into core.

`DateInputTouchSurface` goes with them. It existed for exactly these stories,
and with them gone it has no consumer: the tests drive the real `DateInput`
with a stubbed pointer, which is the more honest thing to test. That leaves no
way to force a surface, which is the right default — the touch picker is
reachable by being on a touch device. An export is easy to add later and
breaking to remove, so it should wait for a caller that needs it.

`TOUCH_POINTER_QUERY` stays. It is a string with no implementation commitment,
and it is the only way for a consumer to ask the question the component asks
and lay out to match.
Reframes one paragraph. The previous wording explained the absence of a
touch-specific story by how badly the sheet renders on a desktop canvas, which
is true but is the weaker reason and reads as an apology for a gap.

There is no gap. Every story under Core/DateInput renders the touch surface on
a touch device, from the same source that renders the text field on a mouse —
that is the entire claim of the change, and the stories demonstrate it by
being unchanged. A story earns its place by showing something a caller can do,
and this adds no prop and no composition to show. A touch-specific story would
demonstrate only its own existence, and would imply a separate thing to adopt.
Reported from a device: open the month/year wheels, flick to three months
earlier, and the month then climbs on its own. Real iOS and the simulator
only — Chrome never shows it, which is the clue to what it is.

There is a cycle in the wiring. A wheel commit steers the calendar behind it
(`scrollToMonth`), the calendar reports the month it lands on, and that report
came straight back as the month — which moves the wheel's selected row, which
repositions the wheel, whose scroll reads as another commit. Whether that
converges rests entirely on how precisely the browser says "scrolling
stopped":

- Chrome has `scrollend`, so a settle only fires at genuine rest, and the
  first lap ends it.
- iOS below Safari 26 has no `scrollend` at all, so the fallback is a quiet
  period — and iOS momentum runs on for a second or more after the finger
  lifts, firing scroll events irregularly, with tail gaps longer than any
  sane quiet period. Each premature settle committed the next month along,
  and the value climbed.

Fixed by removing the cycle rather than damping it: while the wheels are open
they are the source of truth, and the calendar is only being steered, so its
echo is ignored. Nothing about timing has to hold for that to be correct.

Two supporting fixes to the settle itself, both real on their own:

- A finger resting mid-drag is not rest. Hold a wheel still without lifting
  and the scroll events stop, so the timer fired and committed whatever sat
  under the band. It now waits for the touch to end.
- Repositioning a scroller that is still moving fights the platform and, on
  iOS, does not stop the momentum underneath. `useScrollSettle` now reports
  whether the scroller is at rest, and the wheel's park effect refuses to
  move one that is not; the correction still happens, just at rest.

Also fixes the one CI-only lint error from the rebase: MonthYearWheels built
month names with a raw `Intl.DateTimeFormat`, which the new
`@astryx/no-raw-intl-locale` rule forbids. It goes through `plainDateFormat`
now, like every other formatted date here, so the locale traces back to
InternationalizationProvider — with a `DATE_FORMAT_MONTH_ONLY` alongside the
other shared format constants.

The device is the only authoritative test for the loop; the regression test
here drives the echo through the calendar's own scroll listener, which needs
no timing assumptions and fails without the guard.
…e dates

Three things, from another pass on a device.

**The month drifted when switching back to the dates view.** The wheels steer
the calendar while it is hidden behind them, and a hidden scroller is not a
safe place to leave a scroll position: `visibility: hidden` keeps the layout
box, but iOS re-snaps the scroller when it becomes visible again, and not
necessarily onto the pane it was put on. That fires a scroll at the exact
moment reports start being trusted again, so the stray position became the
month.

Two halves to the fix, and they are separate concerns:

- The scroller no longer reports anything while a steer is in flight.
  `scrollToMonth` is only ever called by something that already knows the
  month — a wheel commit, a header arrow — so none of the scrolling it causes
  is news, including whatever it passes through on the way. A touch clears
  the steer: once a finger is on it, the user owns it and every month counts.
- The calendar is put back on the committed month's pane when the wheels
  close. Suppressing the report alone would leave the header naming a month
  the grid underneath is not showing.

**Opening the picker always lands on the dates.** The wheels are a detour
taken to reach a far month, not a mode to be left in — reopening into them
answered a question the user had not asked, and hid the dates they came back
for behind another tap.

**The cross-fade runs on `medium` rather than `fast`.** The scale's own bands
say it: `fast` is for micro-interactions, `medium` for entrance and exit, and
swapping a calendar for a pair of wheels is both. At `fast` it read as a cut,
which tells the wrong story — the two surfaces are one picker, and the fade is
what says so. All four moving parts (panels, weekday row, header arrows, title
chevron) share the duration, so the swap reads as one event.

Each of the four is covered by a test that fails without it.
… turn

**Done is hidden while the wheels are up.** It dismisses the whole sheet, and
offering that mid-detour invites ending the trip early: the wheels were opened
to reach a month, and the way out of them is the title that opened them. Hidden
rather than unmounted, like the arrows — the footer is the sheet's last row, so
dropping it would shorten the sheet mid-fade. `inert` as well, so nothing
invisible is reachable by tab or by a screen reader.

**The fade was two faults, and the reported one is the second.**

First, the easing. `--ease-standard` is `cubic-bezier(0.24, 1, 0.4, 1)` — a
curve for something travelling a distance, and wrong for a fade. Measured: it
put opacity at 50% in 91ms and 95% in 241ms of a 410ms transition. So the fade
was over long before the duration was, and the earlier lengthening of that
duration bought an imperceptible tail rather than a slower fade. Opacity now
runs `linear`; the title chevron keeps the token, because rotation is travel.

Second, and this is the "grey area animates differently": the panels share one
grid cell, so a cross-fade OVERLAYS them. The wheels carry a translucent
selection band, and mid-fade it tinted the strip of calendar grid showing
through underneath — one band-shaped rectangle of the outgoing surface looking
unlike the rest of it. The band was not animating differently; it was painting
on top of the other panel.

Verified it was not the obvious suspect first: sampled frame by frame, the
band's effective opacity tracks the row beneath it to three decimals the whole
way, and neither moves.

So the surfaces take turns rather than overlap. Everything leaving fades out
over the first leg with no delay; everything arriving waits a leg and fades in
over the second. Measured after: zero frames with both panels painting, and
the whole swap still lands at ~400ms, the entrance/exit band it belongs in.

Three existing tests encoded the old footer behaviour and are rewritten rather
than deleted — Done closing the sheet is still covered, from the calendar
where it is now the only place it exists.
CI's lint was red while every local run was green, and the reason is worth
recording: `eslint.config.js` promotes the whole `@eslint-react` and
`react-compiler` family from `warn` to `error` when `CI=true`. So a plain
`npx eslint .` is quieter than CI by construction, and my gate script had been
under-reporting this branch the entire time. It sets `CI=true` now, which
reproduces all seven locally.

Four were real, and two of those were bugs rather than style:

- **A leaked listener.** The mouse-drag hook registered `scrollend` with
  `{once: true}`, and a `once` listener that never fires is never removed —
  unmounting mid-glide left one attached. It is a plain listener now, removed
  by `restoreSnap`, which the cleanup already calls.
- **A dependency that was never a dependency.** MonthYearWheels memoized its
  month names on `locale`, but `plainDateFormat` resolves the locale itself,
  so the value was doing nothing. The context read is gone with it. (Calendar's
  own month labels have the same characteristic.)
- **A comma hardcoded into an aria-label.** The header button's name was
  built as `` `${monthYear}, ${t(...)}` ``, which assumes English punctuation
  and word order. The catalog entry now carries the whole label with a
  `{monthYear}` placeholder, so a translator owns both.
- **A ref named against convention** — `scrollerHandle` -> `scrollerHandleRef`.

One was the rule catching a real smell in the reveal fix: the effect that
re-asserts the calendar's month deliberately omits `monthIndex`, because
listing it would re-run on every month a swipe passes through and yank the
scroller back mid-gesture. That intent is now expressed with a ref rather than
a suppressed warning, so the effect genuinely depends only on the surface
change.

The remaining two are `set-state-in-effect` on measurements — a pane width and
a scroll position, neither of which exists before layout. Annotated with the
reason, which is how BottomSheet handles the same rule in eight places.

pnpm test's `packages/cli` failures in a full run are the known load flake:
2744 CLI tests pass under `vitest run --project node packages/cli`, and CI's
own test job is green.
… seen

**The swap is 220ms, down from ~410.** Sequencing the two halves is what made
it feel slow: the previous change stopped the panels overlapping, which was
right, but it also meant the full duration was spent with one surface or the
other absent. Half the budget per leg gets the whole gesture near
`--duration-fast` while keeping the property that made it worth sequencing.

**The selection band was never really animating, and the numbers say why.**
It was `--color-background-muted` — 4.7% alpha, which puts the whole plate 17
units of colour away from the sheet behind it. So the band's fade had 17
units to happen in while the text beside it travelled 412. Twenty-four times
the amplitude, in the same milliseconds: the text read as fading and the band
read as simply being there at the end. Nothing was out of step; there was
just nothing to see.

`--color-neutral` (10%) doubles the range to 36, which is enough for the fade
to register and still quiet enough to sit under text. Verified against the
rendered colour rather than the token: the band now measures 36 units from
the sheet, and the wheels still read as text-first with the band behind them.

Also checked, since it was the obvious suspect and wrong: the band's opacity
tracks the row on top of it to three decimals across the whole transition,
and neither moves a pixel. Sequencing still holds — zero frames with both
panels painting.

The band's colour is pinned by a test; the duration deliberately is not, since
it is a feel value and a test asserting `110ms` would only ever restate the
line above it.
…lic API

Three things were quietly becoming public API that nothing had asked for.

**The two theme variables are gone**, replaced by `defineConsts` — inlined at
build time, emitting no CSS custom property. The day size was the worrying
one: 44px is the accessibility floor every target in the sheet is held to, and
publishing it as a variable hands themes a way to quietly lower a floor. The
wheel row height is only meaningful against the day size (the pane geometry
derives from both), so it is not independently tunable either — a variable
that is only safe to change in lockstep with another is not really a variable.

**The header button's theme target is gone** with them. `themeProps` publishes
`astryx-date-input-touch-title` as a themeable selector, and that button is
internal structure of the sheet — the field and its toggle icon are the
documented targets and were already there. It carries a `data-` attribute now,
which is how the panels, the scroller and the arrows in this component were
already addressed, and what the tests query.

**So `DateInput.doc.mjs` reverts to exactly what is on main**, along with
`theme/derivedVarRegistry.test.ts`. Both were only being edited to declare the
surface above: the theming contract tests require every rendered `astryx-*`
class and every documented var to be registered, in English and Chinese. No
new surface, nothing to register, no diff.

What remains public from this branch is one export (`TOUCH_POINTER_QUERY`) and
four catalog keys. Every one of these is additive later and awkward to
withdraw once consumers depend on it, which is the asymmetry worth respecting
while the component is new.

Worth naming: the docsite's DateInput page now says nothing about the touch
surface, because that page is generated from the doc file this reverts. The
behaviour is documented in the source headers and the changeset; a proper docs
pass belongs in its own change, against a component that has settled.
Two footer actions now, one per surface, each spanning the sheet.

**Save** replaces Done on the calendar: primary, full width. Worth noting for
whoever reads the code later — the date is already committed by the tap that
chose it, so this still only dismisses. It is named for what the action means
to someone finishing a form rather than for what it does internally, which is
the right way round. The catalog entry says so explicitly, because the old
one told translators the opposite ("word it as finishing/closing, not as
confirming or saving") and would have actively misled them.

**Done** appears under the wheels and returns to the calendar. This reverses
an earlier decision to hide the footer there: the reasoning then was that
offering a dismiss mid-detour invites ending the trip early, which still
holds — so this button is not a dismiss. It finishes the step, and the
calendar's Save is still the only way to finish the task.

It is `secondary`, not primary. Two primaries would say both surfaces are
places you can complete from, and only one of them is.

The two share a grid cell and cross-fade on the panels' two-leg timing, so
the footer is one button tall whichever is showing, the sheet never changes
height mid-swap, and the outgoing action is gone before the incoming one
arrives. `inert` keeps the hidden one out of the accessibility tree rather
than merely out of sight — measured: exactly one is reachable at a time.

Four tests changed rather than were deleted; the behaviour they covered
(Save closes and commits nothing, Done leaves only the wheels, one action at
a time, both full width) is all still asserted, each against a control that
fails without the change.
…re to go

**Clear** joins Save on the calendar's footer, secondary, sharing the row
evenly. It empties the field and returns the calendar to the current month —
both halves, because clearing a date and then being left looking at the month
of the date you just cleared is a half-finished action; the calendar should
look the way it does before anything is chosen.

The "if possible" in the request is the whole subtlety, and it is a real case:
a range can exclude the current month entirely — a booking window that opens
next quarter — and there is then no honest month to move to. Clamping to the
nearest edge would present some other month as though it were today's, so the
move is skipped and the calendar stays put. The value clears either way; that
half never depends on the range. Both paths are tested, and the clamping
version fails the second test.

**The month arrows now hide rather than grey out** at the edges of the range.
A disabled control still says "this is a thing you could do", and at the end
of a range it is not — the range is the whole truth and there is no state the
user can reach where the arrow becomes available, so a permanently greyed
chevron just reads as broken.

They keep their 44px box (`visibility: hidden`, not unmounted), so the
remaining arrow does not slide sideways as an edge is reached and the header
does not reflow — measured at 36px in both states. Being invisible also takes
them out of the accessibility tree, which is what the test asserts: the
element is still in the DOM and still disabled, but no longer reachable by
role. That is also why the test queries by attribute — `visibility: hidden`
strips the accessible name a role query would need.

One existing test changed from asserting "disabled" to asserting "hidden",
which is the behaviour change itself rather than a workaround.
"Sun", where Calendar's own header says "Su". The sheet is full width — about
51px a column against a popover's — so there is room for the form people
actually read, and a picker driven by thumb should not ask anyone to decode
"Tu" against "Th".

No truncation, and no new data. This is CLDR's `abbreviated` width, and
`Intl` produces it natively: verified against the CLDR tables for all 30
locales in the catalog, zero mismatches. So French keeps "dim." with its dot
and Japanese stays a single character, which is what abbreviated means there
— slicing three characters off the 2-letter table would have been wrong in
several of them.

That is also the reason Calendar cannot simply do the same. Its 2-letter row
is CLDR's *short* width, which `Intl` has no way to express — hence the
generated lookup table beside it, the CLDR dependency and the
`check:cldr-weekdays` gate. The 3-letter form needs none of that, so this
change adds a `DATE_FORMAT_WEEKDAY_ONLY` entry to the shared format
vocabulary and nothing else. The generated table, its gate and Calendar are
untouched.

The two surfaces now differ in content rather than only in shape, which is
worth being deliberate about: it is a typographic choice about available
space, the underlying date is identical, and the columns still rotate
together with `weekStartsOn` (checked in en, fr and ja, for sun/mon/sat
starts).

Both existing weekday tests passed unchanged after this, which is a bug in
the tests: `toHaveTextContent('Su')` matches "Sun" as a substring. They
assert exact arrays now, and fail against the old two-letter names.
Reported as "Done is not working", and it was: the button rendered, looked
right, and did nothing when tapped.

The footer kept an `inert={isWheelOpen ? true : undefined}` from the earlier
version where the whole footer was hidden on the wheels. When the wheels then
grew their own Done button INSIDE that same footer, opening them made the
footer inert — and `inert` disables everything inside it, so the button that
had just appeared was dead on arrival. The cells inside already take turns
being inert, so the footer needed none of its own.

Confirmed by hit-testing rather than by reading the code: at the button's
centre, `elementFromPoint` returned a plain div rather than the button, and a
real touch tap left the wheels open. Both are right after the fix, and an
audit of every on-screen control on both surfaces now finds all 38
hit-testable.

**Why the tests said it was fine, which is the more useful part.** Three of
them asserted on the cell's own `inert` attribute, which was correct
throughout — the problem was an ANCESTOR. And jsdom implements no behaviour
for `inert` at all, so the role queries happily found a button that a browser
would refuse to click. Attribute assertions and role queries are both blind
to this in combination.

So the new test walks up from each visible action and asserts no ancestor
above its own cell is inert. It fails when the stale attribute is put back,
which none of the existing tests did.
…sktop does

Muted, selectable, filling the 6x7 grid — the same shape the popover calendar
has always had, so the two surfaces show a month the same way.

**The reason they were left out was wrong, and measurably so.** The original
argument was that a horizontal scroller would put the same date on screen
twice: greyed at the foot of one pane, and again at the head of the next.
Swept across a full pane boundary in 10% steps, that never happens — 42 dates
on screen at every offset, zero duplicated.

There is a structural reason, which is why it holds rather than merely
happening to: both panes are exactly the scrollport wide and share one
7-column grid, so a given weekday column is only ever visible in ONE pane at
a time. A date always sits in its weekday's column. The two facts together
make the overlap impossible, not unlikely.

Spilled days are tappable, because a date you can see should be a date you
can pick; being told to swipe for one already on screen is the odd
behaviour. They need no special handling for the roving tab order either:
`tabbableISO` resolves per pane and only ever names a date in that pane's own
month, so a spilled cell cannot become a second tab stop for a date the
neighbouring pane owns. I had added a redundant guard for that before
checking, and removed it again.

That property is now pinned by a test, with the selection on April 1 —
simultaneously April's own tabbable date and a spill day in March's pane,
which is exactly the case that would break if the resolution ever went
global.
…ay colour

Three small corrections to the touch calendar, all of them about a spilled
day from an adjacent month reading as what it is.

"Clear" became "Reset". The button does two things — empties the field AND
returns the calendar to the current month — and "Clear" only names the first,
so the second read as the button overreaching. "Reset" covers both: the picker
goes back to how it opens.

The spill days now take the desktop calendar's exact treatment. Calendar
splits it across two style objects (`dayCellTheme` carries the secondary
colour, `dayCellStyles` the 0.5 opacity), and this pane had copied only the
colour, so its adjacent days came out heavier than the desktop's and sat too
close to the disabled ones to tell apart.

And `dayOutside` is now applied BEFORE `dayDisabled`, so a spilled day past
min/max paints disabled rather than merely outside. Reversed, a date you
cannot pick looked more available than the ones beside it that you can.

The parity test reads Calendar's own source for both halves rather than
restating the values here, so it follows the desktop if the desktop moves.
…uring them

Following the desktop's adjacent-day colour was only half the job, and the
half left undone was the one that made the two hard to tell apart.

The desktop FADES a disabled day — `opacity: 0.3` over whatever colour it
already had — while this pane painted a flat `--color-text-disabled`. Measured
on white: an enabled adjacent day landed at 168 and a disabled in-month day at
163. Five levels apart, so indistinguishable, and the disabled one the DARKER
of the two, which is backwards: the date you cannot pick read as the more
solid one.

Fading instead reproduces the desktop's four tiers exactly — 23 in-month, 169
adjacent, 185 disabled, 203 disabled-and-adjacent — so availability now reads
straight off the weight, and every disabled day is lighter than every enabled
one.

Measured both surfaces of the same story in a browser, by kind rather than by
date: every tier the desktop shows now appears on touch with identical colour
and opacity. The one tier touch has and the desktop does not is the enabled
adjacent day, which the desktop cannot show at all because it makes its spill
days unclickable. Ours are pickable, so they carry this treatment without the
disabled fade on top — the same style, one fewer layer.
…her than cross-fade

Two changes to the touch picker, both about a surface doing one thing at a
time.

Adjacent-month days are no longer tappable. They were, on the reasoning that
a date you can see is a date you should be able to pick — but a pane IS a
month here, so committing April 1 from March's pane moves the calendar out
from under the thumb that just tapped it. The desktop calendar has always made
its outside days unselectable (`effectivelyDisabled: isDisabled || isOutside`),
and this now matches, along with the two guards beside it: a spilled copy no
longer wears the selection puck or the today ring, which belong to the month
that owns the date. The visible effect is the last tier gap closing — both
surfaces now render exactly three, at 23, 185 and 203 flattened on white.

The wheels now COVER the calendar instead of cross-fading with it. An opaque
plate the colour of the sheet lands over the grid on the first frame, and only
the month and year fade in, against the plate. Nothing belonging to the
calendar animates at all, and no frame ever shows a day number and a year
through each other. Measured on an iPhone profile with the animation clock at
2%: ink inside the grid's box goes 2.5% of pixels (calendar) -> 0.0% at 5% in
-> 0.5 -> 1.7 -> 2.2 -> 2.3 (wheels). Tapping Done uncovers in one frame, so
there is no wait between the tap and the calendar it was for.

Three things this turned up:

- The plate needs `isolation: isolate` to actually cover. Backgrounds and text
  paint in separate phases, so a later sibling's background lands UNDER an
  earlier sibling's text — the plate went in opaque and the calendar's day
  numbers showed straight through it. It had never come up because the old
  panel animated `opacity`, which makes a stacking context by accident.
- The one-directional fade is expressed as "hidden is the base, the transition
  rides on the shown state" rather than a `transition-duration: 0s` override.
  Same behaviour, and `build-css.test.mjs` reserves that declaration for
  inside a `prefers-reduced-motion` block.
- The duration is `--duration-fast` now that it is a whole duration rather
  than half of a two-leg one, so it follows a consumer's motion scale. The
  chevron joins it, having previously run `--duration-medium` against a 220ms
  swap and still been turning after the wheels had settled.
The last pass overshot. Making the wheels an opaque cover fixed the artefact
but took the fade with it: the layer arrived in one frame and left in one
frame, and only its contents crossed. The wheels should fade both ways; it is
the CALENDAR that should not animate.

So the whole wheels panel — plate and content together — is the thing that
fades, and the calendar under it only stops being visible. Nothing about the
calendar is ever seen changing: its opacity is 1 on every frame, and its
`visibility` flips exactly when the cover completes, which CSS times for free
by holding `visible` across a transition whenever either end is `visible`.
Coming back it is present from the first frame, revealed as the layer above
fades off it.

The opaque background stays, and is now doing the job it was always for.
Inside the fading group, the layer renders as one finished image and the fade
applies to the image — so the translucent selection band crosses at exactly
the rate of the text beside it. That was the original complaint: without the
background, the band composited against a live calendar on its own terms and
faded unlike everything around it.

Measured on an iPhone profile with the animation clock at 2%, both directions:
calendar opacity 1 throughout, hidden only once the wheels reach 1.00 and
visible again on the first frame of the return; wheels 0.11 -> 0.32 -> 0.64 ->
0.96 opening and 0.89 -> 0.55 -> 0.22 closing.

The weekday row and the header arrows fade on the same timing rather than
clearing instantly. They are the one part of the calendar the layer cannot
cover, since the plate starts below the header, and blinking them out a beat
ahead of the grid made the surface leave in two pieces.
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