From 2d9fb81dfeafea0de497cf46e15257d3b8a20f6b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:54 -0800 Subject: [PATCH 01/40] fix(DateInput, DateTimeInput): the sheet's calendar pages like UIDatePicker --- .../calendar-pages-like-uidatepicker.md | 16 + apps/storybook/stories/DateInput.stories.tsx | 20 + .../components/DateInput/DateInput.spec.md | 38 +- .../src/DateInput/DateInputTouch.test.tsx | 339 ++++++- packages/core/src/DateInput/MonthPager.tsx | 831 ++++++++++++++++++ packages/core/src/DateInput/MonthScroller.tsx | 25 +- .../core/src/DateInput/TouchDateField.tsx | 8 +- .../MonthPager.interaction.browser.spec.ts | 744 ++++++++++++++++ .../MonthScroller.interaction.browser.spec.ts | 157 +--- ...hScrollerSteps.interaction.browser.spec.ts | 84 +- .../core/src/DateInput/datePickerTouch.ts | 6 +- .../core/src/DateInput/useSheetAxisSplit.ts | 134 +++ .../src/DateTimeInput/TouchDateTimeField.tsx | 5 +- packages/core/src/motion/MEASUREMENTS.md | 11 +- packages/core/src/motion/uikit/datePicker.ts | 67 +- 15 files changed, 2204 insertions(+), 281 deletions(-) create mode 100644 .changeset/calendar-pages-like-uidatepicker.md create mode 100644 packages/core/src/DateInput/MonthPager.tsx create mode 100644 packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts create mode 100644 packages/core/src/DateInput/useSheetAxisSplit.ts diff --git a/.changeset/calendar-pages-like-uidatepicker.md b/.changeset/calendar-pages-like-uidatepicker.md new file mode 100644 index 0000000000000..181a157f54c86 --- /dev/null +++ b/.changeset/calendar-pages-like-uidatepicker.md @@ -0,0 +1,16 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now pages its months like the iOS date picker: + +- A swipe moves at most one month, chosen at the lift by iOS's paging rule: a quick flick, or a drag past half a month. It settles on iOS's paging curve. +- The month title follows the drag and the settle. It changes when the next month is more than half in view. +- The arrows scroll the month in 0.3 s. A second tap mid-scroll goes on to the month after. +- In the sheet, a drag is decided once it has moved 10 px either way. It pages the calendar if it has moved 10 px sideways by then, and moves the sheet otherwise. +- A horizontal trackpad swipe, or Shift with a mouse wheel, pages one month per gesture. +- With Reduce Motion on, the arrows' step and a swipe's settle cross-fade in 200 ms instead of travelling. + +With a mouse, the calendar still scrolls natively. With any pointer, PageUp and PageDown now move the keyboard focus by a month, and by a year with Shift. + +@vjeux diff --git a/apps/storybook/stories/DateInput.stories.tsx b/apps/storybook/stories/DateInput.stories.tsx index 005ba5edd5f86..2ae3b2b3f35f0 100644 --- a/apps/storybook/stories/DateInput.stories.tsx +++ b/apps/storybook/stories/DateInput.stories.tsx @@ -594,6 +594,26 @@ export const WithMinMax: Story = { }, }; +/** + * The bottom-sheet calendar pages its months like the iOS date picker: one + * month per swipe, the title following the drag, the arrows on a 0.3 s + * curve. + */ +export const BottomSheetCalendar: Story = { + render: args => { + const [value, setValue] = useState( + '2026-10-15' as ISODateString, + ); + return ; + }, + args: { + label: 'Date', + presentation: 'bottom-sheet', + min: '2026-01-01' as ISODateString, + max: '2026-12-31' as ISODateString, + }, +}; + export const WithMaxDateInLayout: Story = { render: args => { const [value, setValue] = useState(undefined); diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 3bcec81c67d63..c63775a87851f 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -66,15 +66,15 @@ system_specs: ## Contract at a glance -| Area | Contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `calendar` selects the calendar system the field shows on every surface (`spec:AST-111` FR2); every other API shape and default is as shipped, and every surface follows the input-field `changeAction`, disabled, and busy contracts. | -| Behavior | `presentation` selects the picker surface; an omitted value is `adaptive-native`; `presentation` wins over deprecated `nativePicker`; every committed path emits the same controlled ISO-date value and action feedback. The pointer field reads and writes typed dates in the resolved calendar and reports a typed date only when it is complete (FR20). With a calendar other than `gregory` or `iso8601`, a presentation that would show the native control shows the popover on a fine pointer and the bottom sheet on a coarse pointer, and the bottom sheet shows months of the calendar (FR21). | -| End-user impact | People type or pick one date using a surface suited to the primary pointer, in the calendar the application chooses. Disabled and busy fields stay focusable; a busy field keeps its ordinary paint with a Spinner while new commits are blocked. | -| Builder impact | Optional: set `calendar` on the field or its provider, and `min` and `max` on Buddhist and Minguo calendar fields. `changeAction` runs after `onChange` with optimistic busy feedback on every surface. | -| Compatibility/readiness | `calendar` is additive and the default stays Gregorian. Typed Gregorian entry fires `onChange` only for complete input and keeps an out-of-range date with its error; `parseDateInput` handles only unreadable text and dates the locale's order lacks, and a two-digit year reads within the 80 years before and 19 after the current year, 1946–2045 in 2026 (FR20). Calendars that need `Temporal` fall back to Gregorian where it is missing (the `spec:AST-013` FR12 exception). Under a finger, the sheet wheels' markup changes (one strip of flat rows, faded by a layer inside the column). Under every presentation, keys step through the values in time order (FR17). Caller handlers while disabled, fixed field height, a clear control that drops focus, and month paging in the touch calendar are listed under Current-state impact. | -| Review checks | Reject a surface-mapping change, lost constraint, callback-order change, unlabeled field, native `disabled`, disabled paint on a busy-only field, unreachable disabled reason, changed close behavior, a theming target placed on the wrong owner, a typed date reported before it is complete, an adjusted typed date, or two rejection kinds that share a message. | -| Governing rules | `spec:AST-043/FR1–FR5`; `spec:AST-085/FR1–FR6`; `spec:AST-086/FR3, FR4, FR6, FR8`; `spec:AST-111` FR2, FR3, FR9–FR16, AR2, AR3; `spec:AST-013` FR12; `family:input-fields/FR1–FR6, FR8–FR11`; `family:overlay-dismissal/FR1–FR7`, including its recorded BottomSheet adoption gap; `spec:AST-020/FR15`; `spec:AST-102/FR4`; the four architecture records linked above. | +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `calendar` selects the calendar system the field shows on every surface (`spec:AST-111` FR2); every other API shape and default is as shipped, and every surface follows the input-field `changeAction`, disabled, and busy contracts. | +| Behavior | `presentation` selects the picker surface; an omitted value is `adaptive-native`; `presentation` wins over deprecated `nativePicker`; every committed path emits the same controlled ISO-date value and action feedback. The pointer field reads and writes typed dates in the resolved calendar and reports a typed date only when it is complete (FR20). With a calendar other than `gregory` or `iso8601`, a presentation that would show the native control shows the popover on a fine pointer and the bottom sheet on a coarse pointer, and the bottom sheet shows months of the calendar (FR21). | +| End-user impact | People type or pick one date using a surface suited to the primary pointer, in the calendar the application chooses. Disabled and busy fields stay focusable; a busy field keeps its ordinary paint with a Spinner while new commits are blocked. | +| Builder impact | Optional: set `calendar` on the field or its provider, and `min` and `max` on Buddhist and Minguo calendar fields. `changeAction` runs after `onChange` with optimistic busy feedback on every surface. | +| Compatibility/readiness | `calendar` is additive and the default stays Gregorian. Typed Gregorian entry fires `onChange` only for complete input and keeps an out-of-range date with its error; `parseDateInput` handles only unreadable text and dates the locale's order lacks, and a two-digit year reads within the 80 years before and 19 after the current year, 1946–2045 in 2026 (FR20). Calendars that need `Temporal` fall back to Gregorian where it is missing (the `spec:AST-013` FR12 exception). Under a finger, the sheet wheels' markup changes (one strip of flat rows, faded by a layer inside the column), and the calendar renders MonthPager's month panes instead of the native month scroller. In a sheet wheel, under every pointer, keys step through the values in time order (FR17). Caller handlers while disabled, fixed field height, and a clear control that drops focus are listed under Current-state impact. | +| Review checks | Reject a surface-mapping change, lost constraint, callback-order change, unlabeled field, native `disabled`, disabled paint on a busy-only field, unreachable disabled reason, changed close behavior, a theming target placed on the wrong owner, a typed date reported before it is complete, an adjusted typed date, or two rejection kinds that share a message. | +| Governing rules | `spec:AST-043/FR1–FR5`; `spec:AST-085/FR1–FR6`; `spec:AST-086/FR3, FR4, FR6, FR8`; `spec:AST-111` FR2, FR3, FR9–FR16, AR2, AR3; `spec:AST-013` FR12; `family:input-fields/FR1–FR6, FR8–FR11`; `family:overlay-dismissal/FR1–FR7`, including its recorded BottomSheet adoption gap; `spec:AST-020/FR15`; `spec:AST-102/FR4`; the four architecture records linked above. | This table is a review projection; the body below is authoritative. @@ -88,8 +88,9 @@ browser/OS date picker selected by the public presentation contract. - Released default preserved: `yes` - Compatibility class: additive `calendar` prop with a Gregorian default; under - a finger the sheet wheels' markup changes, and under every presentation keys - step through the values in time order (FR17). Typed Gregorian entry fires + a finger the sheet wheels' markup changes and the calendar renders + MonthPager's month panes, and in a sheet wheel, under every pointer, keys step + through the values in time order (FR17). Typed Gregorian entry fires `onChange` only for complete input, a nonbreaking fix that restores FR1's next-ISO-date contract; its rejection messages are additive; on Enter or blur, `parseDateInput` still reads text the field cannot otherwise read and a date @@ -249,8 +250,6 @@ Consumer migration instructions belong in consumer docs and release notes. without a render, and the visible month is reported once per half-month crossed, during the drag and the settle that ends it. Listeners, frames, timers, and observers are cleaned up on deactivation or unmount. - Implementation gap: under a finger the touch calendar moves through the native - month scroller; it has no drag-driven month pager. ## Accessibility contract @@ -287,9 +286,7 @@ Consumer migration instructions belong in consumer docs and release notes. following the reading direction, ArrowUp and ArrowDown by a week, Home and End to the ends of the week, PageUp and PageDown by a month, and Enter or Space picks the focused day. Named exception: Shift+PageUp and Shift+PageDown move - by a year, as in the APG date picker dialog. Implementation gap: PageUp and - PageDown do not page by a month, and Shift+PageUp and Shift+PageDown do not - move by a year. + by a year, as in the APG date picker dialog. - **AR9 — The field names its calendar** (`spec:AST-111` AR2). The pointer field's accessible description names the calendar only when the field's resolved calendar differs from the provider's resolved calendar, else @@ -407,9 +404,9 @@ clear-glyph target of its own. | FR12, AR4 | `DateInput.test.tsx` disabled, loading, and disabledMessage cases (including "does not call a caller onClick while disabled (AST-085 FR2)" and "links the reason tooltip from the calendar button too (AST-085 FR5)"); `DateInputTouch.test.tsx` open-sheet disablement and disabled cases; `NativeDateField.test.tsx` "keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)", "does not call a caller onClick while disabled (AST-085 FR2)"; `DateInput.test.tsx` disabled theme-state cases | Disabled with and without a reason; loading; open picker becoming disabled | Native `disabled`, a change or open while disabled, a caller `onClick` while disabled, a reason not linked from each focusable control, or a missing `aria-busy` fails. Evidence gap: no test asserts that caller key, paste, or pointer handlers are skipped while disabled (the FR12 gap) or the busy-only paint. | `audit:DateInput/accessibility` | | FR12 | `DateInput.test.tsx` "typed text while the field is disabled or busy" cases and "keeps a cleared draft without committing it when the field becomes disabled (AST-085 FR2)" | `isLoading`, `isDisabled`, and a `changeAction` save that takes or fails; unfinished, valid, reported, and cleared text; shown rejection; new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateInput/behavior` | | AR1, AR3, AR5 | `DateInput.test.tsx` label, required, description, status, and invalid-input cases ("announces an alert message when typed input is invalid", "resolves the invalid-date announcement from the i18n catalog"); `NativeDateField.test.tsx` "keeps the overlay out of the accessibility tree", "keeps the field labelled, required-marked, and ref-forwarded"; `DateInputTouch.test.tsx` "associates the label natively, so the field is named without ARIA" | Every surface | A missing label, required, description, status, or invalid announcement, or an exposed native overlay, fails. | `audit:DateInput/accessibility` | -| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable") | Touch calendar and wheels; RTL | A second Tab stop per month, a wrong arrow move, reversed RTL arrows, an unbounded month window, or more than one reachable footer action fails. Evidence gap: no test asserts Home, End, PageUp, PageDown, Shift+PageUp, Shift+PageDown (the AR8 gap), or Enter and Space on a day. | `audit:DateInput/accessibility` | +| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today", "pages the keyboard focus by month with PageUp/PageDown and by year with Shift, on every pointer", "under a finger: pages the keyboard focus by month with PageUp/PageDown and by year with Shift", "under a finger: only the month the pager shows takes a tab stop", "under a finger: gives a spilled date no tab stop, and the off-screen month none at all") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable") | Touch calendar and wheels; RTL | A second Tab stop per month, a wrong arrow move, reversed RTL arrows, an unbounded month window, or more than one reachable footer action fails. Evidence gap: no test asserts Home, End, or Enter and Space on a day. | `audit:DateInput/accessibility` | | AR7 | `apps/storybook/rtl-audit/targets.json` DateInput entry; `DateInputTouch.test.tsx` RTL scroll-offset cases | RTL | A changed audit target or RTL offset fails. | `audit:DateInput/rtl` | -| PR2 | `MonthScrollerSettle.test.tsx` cases; `DateInputTouch.test.tsx` "a rest position between two months" cases | Settle and re-snap | A scroller that never settles or corrects mid-gesture fails. Evidence gap: no test asserts listener, frame, and observer cleanup on unmount, drags a month pager, or counts the visible-month reports per half-month (the PR2 gap). | `audit:DateInput/behavior` | +| PR2 | `MonthScrollerSettle.test.tsx` cases; `DateInputTouch.test.tsx` "a rest position between two months" cases and "under a finger: MonthPager pages the months with the shared pager, each pane on its own layer"; `MonthPager.interaction.browser.spec.ts` "the title follows the drag, changing at half a month", "a flick released before half a month turns the title as the settle crosses half, not at the lift", "a slow release before half a month returns; past it, the next month" | Settle and re-snap | A scroller that never settles or corrects mid-gesture fails. Evidence gap: no test asserts listener, frame, and observer cleanup on unmount. | `audit:DateInput/behavior` | | FR13 | `Wheel.test.tsx`, the wheel every sheet renders: "stopping below the minimum travels on to the minimum and commits it there", "stopping past the maximum travels back to the maximum", "a stop on another disabled row still travels to the bounds", "opening on a value outside the bounds parks on it without travelling or committing", "a drag that comes back to rest on a committed value outside the bounds stays there and commits nothing", "never commits a disabled row, even with nowhere valid to go", and `nearestEnabledIndex` "breaks a tie toward the committed row"; `PickerWheel.test.tsx`, the wheel under a finger: "turns on to the minimum after resting below it, and commits that", "opening on a value outside the bounds commits nothing", "rests back on a committed value outside the bounds and commits nothing"; `WheelRest.a11y.chromium.spec.ts` in the TimeInput sheet: "a wheel dragged below the minimum comes to rest on the minimum", "a wheel dragged onto a row inside the bounds commits that row"; `DateInputTouch.test.tsx` "bounds the year wheel to the reachable range" (rows outside the bounds are disabled). | Below the minimum, above the maximum, on another disabled row; opened on a value outside the bounds and rested back on it after a drag; DateInput, TimeInput, and DateTimeInput sheets | A wheel that returns to its committed row, skips the nearest enabled row, breaks a tie away from the committed row, or commits on opening or on a rest back on its committed row fails. | `audit:DateInput/behavior` | | FR14 | `Wheel.test.tsx` "turns onto a value set while it is shown, as a bound clamping the time after another wheel turned"; `WheelRest.a11y.chromium.spec.ts` in the TimeInput sheet: "a meridiem change past the maximum turns the hour wheel onto it, frame by frame"; `PickerWheel.test.tsx`, the wheel under a finger: "turns the wheel onto its row, as a bound clamping the time", "turns on from where the wheel is drawn when the value changes again during that turn", "turns onto a tapped row and commits it on arrival", "turns onto a tapped row on the settle spring, from rest, and commits it on arrival", "keeps the spring whatever the theme’s duration and easing tokens", "turns onto a key’s row on the same spring", "commits each arrow key at once, stepping from the row it is heading to", "commits a clicked row at once and never a disabled one". | A bound clamping the value of a shown wheel after another wheel turned, in DateInput, TimeInput, and DateTimeInput sheets; a tapped row, an arrow key and a row click | A wheel that jumps to the new row fails, and so does a tap that commits at the lift, or a key or click that waits for a turn. | `audit:DateInput/behavior` | | FR15 | `WheelPinchZoom.a11y.chromium.spec.ts` "a pinch over an open picker wheel zooms the page and changes no value"; `DateInputTouch.test.tsx` "leaves a two-finger pinch to the page over the wheels". `PickerWheel.test.tsx` "follows a trackpad’s fingers, coasts on at their lift and rests on a row, as a finger’s flick does", "rests on the nearest row after a slow trackpad drag that stops, without coasting", "turns one row for a mouse wheel’s notch, as an arrow key does, with no coast", "turns three rows for three notches, one each", "leaves a ctrl-wheel, a trackpad pinch, to the page"; `Wheel.interaction.browser.spec.ts` "one mouse-wheel notch turns the touch column one row, as an arrow key does; three turn it three" (Chromium) and the trackpad flick and slow stopped drag (WebKit, whose driver sends a trackpad's deltas). | A trackpad scroll and its lift; a slow stopped trackpad drag; one and three mouse-wheel notches; a ctrl-scroll over an open wheel | A trackpad scroll that does not follow, a lift that stops dead or coasts at the browser's rate, a notch that turns more or less than one row or coasts, or a ctrl-scroll that turns the wheel fails. | `audit:DateInput/behavior` | @@ -436,11 +433,6 @@ clear-glyph target of its own. ticks with a short vibration where the browser has the Vibration API, as on Android. iOS Safari has none, so the tick is silent there (`spec:AST-094` FR23). -- **Month paging (AR8).** PageUp and PageDown do not page the touch calendar by - a month, and Shift+PageUp and Shift+PageDown do not move it by a year, as AR8 - states they do. -- **Month pager (PR2).** Under a finger the touch calendar moves through the - native month scroller rather than a drag-driven pager. ## Decision log diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 790f8aad6053c..027f226ad9c31 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -61,6 +61,15 @@ import {WHEEL_ROW_HEIGHT} from './wheelOptions'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; +import {useCalendarDays} from '../Calendar'; +import type * as CalendarModule from '../Calendar'; + +// A pass-through spy: each call is one month pane rendering, which the +// month/year toggle test counts. +vi.mock('../Calendar', async importOriginal => { + const actual = await importOriginal(); + return {...actual, useCalendarDays: vi.fn(actual.useCalendarDays)}; +}); // --------------------------------------------------------------------------- // jsdom scaffolding @@ -136,8 +145,9 @@ function stubMedia({ /** * A touchscreen laptop with the sheet pinned (`presentation="bottom-sheet"`). - * Its primary pointer is fine, so the sheet's wheels are the scrolling - * columns. A finger can still turn them. + * Its primary pointer is fine, so the sheet's wheels are the scrolling columns + * and the calendar is the native scroller. A finger can still turn and swipe + * them. Under a primary finger the calendar is MonthPager instead. */ function stubTouchscreenLaptop(): void { stubMedia({pointer: 'fine', anyPointer: 'coarse', width: 1280}); @@ -967,6 +977,100 @@ describe('DateInput — calendar surface', () => { expect(header()).toHaveTextContent('March 2026'); }); + it('under a finger: names the month at the tap and steps again from it mid-scroll', () => { + renderAndOpen( + , + ); + const header = () => + document.querySelector('[data-title="month-year"]')!; + fireEvent.click(screen.getByRole('button', {name: 'Next month'})); + expect(header()).toHaveTextContent('April 2026'); + fireEvent.click(screen.getByRole('button', {name: 'Next month'})); + expect(header()).toHaveTextContent('May 2026'); + // The window follows the target, so May's pane is mounted already. + expect(pane('May 2026')).toBeInTheDocument(); + }); + + it('pages the keyboard focus by month with PageUp/PageDown and by year with Shift, on every pointer', () => { + renderAndOpen( + , + ); + const day = within(pane('March 2026')).getByRole('button', { + name: /March 21, 2026/, + }); + day.focus(); + // The native scroller follows the focus with a smooth scroll, which jsdom + // does not run. The focus itself moves on every pointer. + fireEvent.keyDown(day, {key: 'PageDown'}); + expect(document.activeElement).toHaveAttribute('data-date', '2026-04-21'); + fireEvent.keyDown(document.activeElement!, {key: 'PageUp', shiftKey: true}); + expect(document.activeElement).toHaveAttribute('data-date', '2025-04-21'); + }); + + it('under a finger: only the month the pager shows takes a tab stop', () => { + // The neighbouring months are mounted off-screen; Tab into one would + // scroll the clipped viewport under the pager's track. + renderAndOpen( + , + ); + const tabStops = () => + [ + ...document.querySelectorAll( + '[data-scroller="months"] [data-date]', + ), + ].filter(day => day.tabIndex === 0); + expect(within(pane('April 2026')).queryAllByRole('button').length).toBe(42); + expect(tabStops().map(day => day.dataset.date)).toEqual(['2026-03-21']); + fireEvent.click(screen.getByRole('button', {name: 'Next month'})); + expect(tabStops().map(day => day.dataset.date)).toEqual(['2026-04-01']); + }); + + it('with a mouse: PageUp/PageDown take the title and the arrows with the focus', () => { + // The native scroller drops every scroll report while a steered scroll + // runs, its arrival included. So the month is reported with the focus + // move itself. + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + renderAndOpen( + , + ); + const header = () => + document.querySelector('[data-title="month-year"]')!; + const day = within(pane('March 2026')).getByRole('button', { + name: /March 21, 2026/, + }); + day.focus(); + fireEvent.keyDown(day, {key: 'PageDown'}); + expect(document.activeElement).toHaveAttribute('data-date', '2026-04-21'); + expect(header()).toHaveTextContent('April 2026'); + fireEvent.keyDown(document.activeElement!, {key: 'PageUp', shiftKey: true}); + expect(document.activeElement).toHaveAttribute('data-date', '2025-04-21'); + expect(header()).toHaveTextContent('April 2025'); + }); + + it('under a finger: pages the keyboard focus by month with PageUp/PageDown and by year with Shift', () => { + renderAndOpen( + , + ); + const header = () => + document.querySelector('[data-title="month-year"]')!; + const day = within(pane('March 2026')).getByRole('button', { + name: /March 21, 2026/, + }); + day.focus(); + fireEvent.keyDown(day, {key: 'PageDown'}); + expect(document.activeElement).toHaveAttribute('data-date', '2026-04-21'); + expect(header()).toHaveTextContent('April 2026'); + fireEvent.keyDown(document.activeElement!, {key: 'PageUp', shiftKey: true}); + expect(document.activeElement).toHaveAttribute('data-date', '2025-04-21'); + expect(header()).toHaveTextContent('April 2025'); + }); + /** * A programmatic scroll must not report its own arrival as if the user had * scrolled there. Nothing needs the report — whatever asked for the scroll @@ -979,9 +1083,16 @@ describe('DateInput — calendar surface', () => { * again — so the month drifted on the way back to the calendar. */ it('does not report a scroll it was told to make', async () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); await withLayout(async () => { render( - , + , ); fireEvent.click(field()); const header = () => @@ -1268,21 +1379,39 @@ describe('DateInput — calendar surface', () => { * break if it ever became global, since April 1 is both the selection and * a spill day in March's pane. */ - it('gives a spilled date no tab stop in the pane that borrows it', () => { + const tabbableIn = (label: string) => + within(pane(label)) + .getAllByRole('button') + .filter(b => b.getAttribute('tabindex') === '0') + .map(b => b.getAttribute('aria-label')); + + it('under a finger: gives a spilled date no tab stop, and the off-screen month none at all', () => { renderAndOpen( , ); - const tabbableIn = (label: string) => - within(pane(label)) - .getAllByRole('button') - .filter(b => b.getAttribute('tabindex') === '0') - .map(b => b.getAttribute('aria-label')); + // April owns the date, so its stop is the date itself. March, mounted + // off-screen beside it, takes none: only the shown month has a tab stop. + expect(tabbableIn('April 2026')).toHaveLength(1); + expect(tabbableIn('April 2026')[0]).toMatch(/April 1, 2026/); + expect(tabbableIn('March 2026')).toHaveLength(0); + }); - // April owns the date, so its stop is the date itself. + it('with a mouse: gives a spilled date no tab stop in the pane that borrows it', () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + renderAndOpen( + , + ); + // The native scroller keeps one stop in every pane. March shows the same + // date, but its stop falls back to a March date, never a day it does not + // own. expect(tabbableIn('April 2026')).toHaveLength(1); expect(tabbableIn('April 2026')[0]).toMatch(/April 1, 2026/); - // March shows the same date, but its stop falls back to a March date — - // every pane keeps exactly one, and never on a day it does not own. expect(tabbableIn('March 2026')).toHaveLength(1); expect(tabbableIn('March 2026')[0]).toMatch(/March/); }); @@ -1466,8 +1595,18 @@ describe('DateInput — a rest position between two months', () => { scroller.dispatchEvent(touchEvent('touchend')); }; + // Rest positions between two months are the native scroller's; under a + // primary finger the calendar is MonthPager, which rests only on a month. const openCalendar = () => { - render(); + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + render( + , + ); fireEvent.click(screen.getByLabelText('Event date')); const scroller = document.querySelector( '[data-scroller="months"]', @@ -1684,6 +1823,23 @@ describe('DateInput — month/year wheels', () => { expect(title()).toHaveTextContent('September 2026'); }); + it('under a finger: moves the hidden calendar to the wheels\u2019 month without an echo', () => { + withLayout(() => { + render(); + fireEvent.click(field()); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText( + 'January', + ), + ); + expect(title()).toHaveTextContent('January 2026'); + fireEvent.click(title()); + expect(title()).toHaveTextContent('January 2026'); + expect(pane('January 2026')).toBeInTheDocument(); + }); + }); + /** * A wheel commit steers the hidden calendar, and the calendar reports the * month it lands on. Taking that report back while the wheels are open @@ -1744,8 +1900,16 @@ describe('DateInput — month/year wheels', () => { * which is what the re-snap amounts to. */ it('holds the month when the hidden calendar is re-snapped on reveal', async () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); await withLayout(async () => { - render(); + render( + , + ); fireEvent.click(field()); openWheels(); fireEvent.click( @@ -2336,8 +2500,9 @@ describe('DateInput — nested scrollers keep their own touch gesture', () => { // and the sheet read every downward drag as a dismiss. Paging sideways // removes the conflict: horizontal pans stay here and vertical ones go to // the sheet, so a downward drag can go back to meaning swipe-to-dismiss. - // A move with no direction yet (the same point twice) is nobody's. - expect(ancestor.seen).toEqual(['touchstart', 'touchmove']); + // The touch's start reaches the sheet; a move with no direction yet (the + // same point twice) is held until the drag has one. + expect(ancestor.seen).toEqual(['touchstart']); ancestor.stop(); }); @@ -2361,10 +2526,120 @@ describe('DateInput — nested scrollers keep their own touch gesture', () => { ancestor.stop(); }); - it('keeps a diagonal drag, because a thumb arcs as it swipes', () => { + it('under a finger: keeps a drag up to 45° from the sheet', () => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const ancestor = watchAncestor(); + // ~42° off horizontal. + swipe(scroller, -80, 72); + expect(ancestor.seen).not.toContain('touchmove'); + ancestor.stop(); + }); + + /** + * A drag from `origin` through `points` (offsets from it), then the lift: + * each point is one delivered move, so the steps are the sampling. + */ + const dragThrough = ( + el: Element, + points: ReadonlyArray, + ) => { + const origin = {x: 150, y: 200}; + touch(el, 'touchstart', origin); + for (const [dx, dy] of points) { + touch(el, 'touchmove', {x: origin.x + dx, y: origin.y + dy}); + } + touch(el, 'touchend'); + }; + + /** `count` moves of `step` px along `degrees` off horizontal, leftward. */ + const stepsAlong = (degrees: number, step: number, count: number) => { + const radians = (degrees * Math.PI) / 180; + return Array.from( + {length: count}, + (_, i) => + [ + -Math.cos(radians) * step * (i + 1), + Math.sin(radians) * step * (i + 1), + ] as const, + ); + }; + + /** + * UIKit's split in a sheet (motion/MEASUREMENTS.md M-DP01). A drag is + * decided on the first move where either axis travels 10 px. It is the + * calendar's if the sideways travel has reached 10 px, else the sheet's. + * The first moves are the UIKit lab's own samples past the threshold. + */ + it.each([ + [10, 11, 'calendar'], + [10, 13, 'calendar'], + [10.7, 12.7, 'calendar'], + [9.7, 11.3, 'sheet'], + [8, 10.3, 'sheet'], + [8.7, 12.3, 'sheet'], + ] as const)( + 'under a finger: a first move past 10 px at (%f, %f) makes the drag the %s’s', + (dx, dy, owner) => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const ancestor = watchAncestor(); + dragThrough(scroller, [ + [-dx, dy], + [-dx * 2, dy * 2], + [-dx * 4, dy * 4], + ]); + expect(ancestor.seen.includes('touchmove')).toBe(owner === 'sheet'); + ancestor.stop(); + }, + ); + + it('under a finger: a fast drag steeper than 45° is the calendar’s when its first move jumps 10 px sideways', () => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const ancestor = watchAncestor(); + // 55° at about 800 px/s: the first move lands 20 px out, 11.5 px sideways. + dragThrough(scroller, stepsAlong(55, 20, 5)); + expect(ancestor.seen).not.toContain('touchmove'); + ancestor.stop(); + }); + + it('under a finger: gives a slow drag steeper than 45° to the sheet', () => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const ancestor = watchAncestor(); + // 55° in 3.3 px steps: the vertical travel reaches 10 px first. + dragThrough(scroller, stepsAlong(55, 3.3, 30)); + expect(ancestor.seen).toContain('touchmove'); + ancestor.stop(); + }); + + it('under a finger: holds a touch’s moves from the sheet until it has travelled 10 px either way', () => { renderAndOpen(); const scroller = document.querySelector('[data-scroller="months"]')!; const ancestor = watchAncestor(); + const origin = {x: 150, y: 200}; + touch(scroller, 'touchstart', origin); + // The sheet promotes its own drag at 8 px: these moves would start it + // before the decision. + touch(scroller, 'touchmove', {x: origin.x - 5, y: origin.y + 6}); + touch(scroller, 'touchmove', {x: origin.x - 7, y: origin.y + 8.5}); + expect(ancestor.seen).toEqual(['touchstart']); + // The vertical travel reaches 10 px first: the sheet's from this move. + touch(scroller, 'touchmove', {x: origin.x - 8, y: origin.y + 10.3}); + expect(ancestor.seen).toEqual(['touchstart', 'touchmove']); + touch(scroller, 'touchend'); + ancestor.stop(); + }); + + it('keeps a diagonal drag, because a thumb arcs as it swipes', () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + renderAndOpen( + , + ); + const scroller = document.querySelector('[data-scroller="months"]')!; + const ancestor = watchAncestor(); // ~50° off horizontal: past the browser's own pan-x cone, still ours. swipe(scroller, -60, 72); expect(ancestor.seen).not.toContain('touchmove'); @@ -2378,7 +2653,11 @@ describe('DateInput — nested scrollers keep their own touch gesture', () => { * from 45° to 60° on an iPhone 15 profile. */ it('pages a month itself when the browser refuses to pan a claimed swipe', () => { - renderAndOpen(); + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + renderAndOpen( + , + ); const scroller = document.querySelector('[data-scroller="months"]')!; const forward = swipe(scroller, -60, 72); @@ -2865,6 +3144,30 @@ describe('DateInput — scroll CSS (definition-level)', () => { ).toHaveLength(2); }); + it('under a finger: the month/year toggle leaves the calendar\u2019s days alone', () => { + // The toggle re-renders the sheet, but nothing the calendar shows changes. + // So no month pane may re-render: each formats 42 day labels. + renderAndOpen(); + vi.mocked(useCalendarDays).mockClear(); + fireEvent.click(document.querySelector('[data-title="month-year"]')!); + expect(vi.mocked(useCalendarDays)).not.toHaveBeenCalled(); + }); + + it('under a finger: MonthPager pages the months with the shared pager, each pane on its own layer', () => { + const source = read('MonthPager.tsx'); + // UIKit's paging scroll view, not native scroll snapping: iOS gives a + // snapping scroller the fast rate bent onto WebKit's own target. + expect(source).toContain('usePager('); + expect(source).not.toContain('scrollSnapType'); + // Mobile Safari paints a layer whole before it moves. + expect(source).toContain("willChange: 'transform'"); + // The pane and the viewport must come from the same expression, or a + // pane stops being exactly one screen. + expect( + source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g), + ).toHaveLength(2); + }); + it('positions panes with logical properties, so RTL mirrors', () => { const source = read('MonthScroller.tsx'); // Scoped to the style objects: `scrollTo({left})` is the DOM API and is diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx new file mode 100644 index 0000000000000..86b6ce6795c58 --- /dev/null +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -0,0 +1,831 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file MonthPager.tsx + * @input Month bounds, selected value, constraints, week start + * @output Exports MonthPager, MonthScroller's counterpart with the same props + * and handle + * @position Internal component of the pickers' touch layer + * (datePickerTouch.ts). The touch date fields render it under a finger. + * + * The month grid pages like UIDatePicker's paging scroll view, on the shared + * pager (hooks/usePager.ts). A few fixed six-row panes are mounted. + * + * SYNC: When modified, update MonthScroller.tsx, TouchDateField.tsx, + * TouchDateTimeField.tsx and DateInputTouch.test.tsx. + */ + +import { + memo, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; +import {usePager} from '../hooks/usePager'; +import {useDirection} from '../i18n/useDirection'; +import {useLocale} from '../i18n/useLocale'; +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; +import { + colorVars, + radiusVars, + fontWeightVars, + typeScaleVars, + durationVars, + borderVars, +} from '../theme/tokens.stylex'; +import {focusOutlineStyles} from '../utils/focusOutline.stylex'; +import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; +import { + plainDateAddDays, + plainDateDayOfWeek, + plainDateFormatDayNumber, + plainDateIsEqual, + plainDateToISO, + DATE_FORMAT_WITH_WEEKDAY, +} from '../utils/plainDate'; +import { + calendarShiftMonths, + calendarShiftYears, + calendarYearMonthOf, + formatCalendarDate, + formatCalendarMonthLabel, + isInCalendarMonth, +} from '../utils/calendarSystem'; +import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; +import type {MonthScrollerProps} from './MonthScroller'; +import {useSheetAxisSplit} from './useSheetAxisSplit'; +import {type MonthModel, clampIndex, paneWindow, rowsIn} from './monthGeometry'; + +const DAY_SIZE = dateInputTouchSizes.daySize; + +/** + * Panes mounted on each side of the target month: UIKit's calendar keeps + * seven pages. A release or a chevron tap moves one month at most. So the + * window follows the target and always holds the pages on screen. + */ +const OVERSCAN = 3; + +/** + * A horizontal wheel gesture, a trackpad's two-finger swipe or Shift with a + * mouse wheel, pages one month once it travels this far. A gesture ends + * after this long without a wheel event, its momentum included. + * + * @source GUESS — not UIKit values: UIKit's grid follows a trackpad's scroll + * and pages on its release rule, which this step stands in for; 25 px turns + * a month on a short deliberate swipe but not on a nudge, and 120 ms + * outlasts the gap between a swipe's momentum events + * @measure M-DP02 + */ +const WHEEL_STEP_PX = 25; +const WHEEL_QUIET_MS = 120; + +const styles = stylex.create({ + viewport: { + position: 'relative', + blockSize: dateInputTouchGeometry.paneBlockSize, + // Stated, not inherited from the reset: the pane size and the pager's + // page width key off the measured box. + boxSizing: 'border-box', + overflow: 'hidden', + }, + /** The row of panes the pager translates. */ + track: { + position: 'absolute', + insetBlock: 0, + insetInlineStart: 0, + inlineSize: '100%', + // No native pan on either axis: horizontal drags are the pager's and + // vertical ones the sheet's, both in JavaScript, as on the sheet's own + // surface. The pager leaves the viewport `pan-y`. In a sheet that lets the + // browser start a vertical scroll and throttle the sheet's drag moves. + // Pinch-zoom stays the browser's. + touchAction: 'pinch-zoom', + }, + pane: { + position: 'absolute', + insetBlock: 0, + blockSize: dateInputTouchGeometry.paneBlockSize, + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gridTemplateRows: dateInputTouchGeometry.paneRows, + // Its own layer, painted whole before it moves: through a busy main + // thread, Mobile Safari moves the track but cannot paint a new page. + willChange: 'transform', + }, + row: { + display: 'contents', + }, + cell: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + day: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: '100%', + blockSize: '100%', + minInlineSize: DAY_SIZE, + minBlockSize: DAY_SIZE, + padding: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + fontSize: typeScaleVars['--text-body-size'], + fontWeight: fontWeightVars['--font-weight-normal'], + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + // A tap on a 44px target should not also select the number inside it. + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }, + /** + * The circular hit/selection puck inside the tap target. Sizing the puck + * rather than the button keeps the touch target at the full cell while the + * visual stays a tidy circle. + */ + puck: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: `calc(${DAY_SIZE} - 8px)`, + blockSize: `calc(${DAY_SIZE} - 8px)`, + borderRadius: radiusVars['--radius-full'], + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: 'transparent', + backgroundColor: 'transparent', + transitionProperty: 'background-color, color, border-color', + transitionDuration: durationVars['--duration-fast'], + }, + puckHoverable: { + // Two guards, for two different failure modes. + // + // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the + // last tapped day until something else is tapped. + // + // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a + // disabled control's events, not its hover styling, so an unguarded + // :hover tints a day you cannot pick. The disabled state sits on the + // button, and this puck is the span inside it. So the call site's gate + // does the work, and this guard holds if the styles move to the button. + backgroundColor: { + default: 'transparent', + '@media (hover: hover)': { + default: 'transparent', + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-overlay-hover'], + }, + }, + }, + puckToday: { + borderColor: colorVars['--color-border-emphasized'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + puckSelected: { + backgroundColor: { + default: colorVars['--color-accent'], + // Holds the accent through hover, overriding puckHoverable's tint. + // Same guarded selector, so the two have equal specificity and + // application order decides — which is what puts this one on top. + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-accent'], + }, + borderColor: colorVars['--color-accent'], + color: colorVars['--color-on-accent'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + /** + * A day belonging to the month either side of this pane. It is muted and + * unselectable, as in Calendar: it shows where the month ends. A tap out of + * the pane would move the calendar under the thumb. + * + * It takes no tab stop: `tabbableISO` names only a date in its own pane's + * month, and only the shown pane (`isCurrent`) has a tab stop. So a date + * never has two tab stops. + */ + dayOutside: { + // Calendar's spill-day treatment: the secondary text colour and half + // opacity, so these days read as background, not as dimmer choices. + color: colorVars['--color-text-secondary'], + opacity: 0.5, + }, + dayDisabled: { + // Calendar's disabled treatment: it fades the day's own colour to 0.3. + // So a disabled in-month day and a disabled spill day stay a step apart, + // and both read lighter than an enabled spill day. + opacity: 0.3, + cursor: 'default', + }, +}); + +const dynamic = stylex.create({ + // insetInlineStart, not left: under RTL the panes lay out from the right, + // and the pager mirrors its track with them. + pane: (insetInlineStart: number, inlineSize: number) => ({ + insetInlineStart: `${insetInlineStart}px`, + inlineSize: `${inlineSize}px`, + }), +}); + +/** + * A run of month grids paged like UIDatePicker's inline calendar. + */ +function MonthPagerComponent({ + handleRef, + monthModel, + minMonthIndex, + maxMonthIndex, + initialMonthIndex, + onVisibleMonthChange, + selectedDate, + today, + isDateDisabled, + weekStartsOn, + onSelect, +}: MonthScrollerProps) { + const viewportRef = useRef(null); + const trackRef = useRef(null); + const rowCount = maxMonthIndex - minMonthIndex + 1; + const initialRow = clampIndex( + initialMonthIndex - minMonthIndex, + 0, + rowCount - 1, + ); + + // Pane width is read from layout, never assumed, so the panes follow a + // resize (rotation, a split-screen change). The pager measures the same + // box for its page width. + const [paneSize, setPaneSize] = useState(0); + const paneSizeRef = useRef(0); + // The pager's target month: the mounted window follows it. + const [centerRow, setCenterRow] = useState(initialRow); + // The month last reported to the owner, so a drag reports each crossing + // once. + const reportedRowRef = useRef(initialRow); + // A far jump waits for its pane to mount before the track moves. + const pendingRowRef = useRef(null); + + // Keyboard focus moves by date, not by cell index, so it crosses month + // boundaries the way a calendar should. Null until the user takes the grid + // with the keyboard. + const [focusedDate, setFocusedDate] = useState(null); + const shouldRestoreFocusRef = useRef(false); + + const onVisibleMonthChangeRef = useRef(onVisibleMonthChange); + useLayoutEffect(() => { + onVisibleMonthChangeRef.current = onVisibleMonthChange; + }); + + const report = useCallback( + (row: number) => { + if (row === reportedRowRef.current) { + return; + } + reportedRowRef.current = row; + onVisibleMonthChangeRef.current(minMonthIndex + row); + }, + [minMonthIndex], + ); + + const pager = usePager({ + viewportRef, + trackRef, + count: rowCount, + defaultIndex: initialRow, + onIndexChange: (index, reason) => { + setCenterRow(index); + // The settle reports a release's month as the content crosses half a + // page (below). A steered move comes from an owner that knows its month. + if (reason !== 'drag') { + reportedRowRef.current = index; + } + }, + // The settle has come to rest: whatever the frames showed, the title + // names the month the grid rests on. + onSettle: index => report(index), + // UIKit's split in a sheet (useSheetAxisSplit, MEASUREMENTS.md M-DP01). + // The pan begins on the first move where either axis travels its + // hysteresis, and only if the sideways travel has reached it. Like every + // pan, it claims its touch only when it begins, on the move the sheet's + // split decides on. + panOptions: { + axisRatio: 0, + shouldBegin: recognizer => + Math.abs(recognizer.totalTranslation().x) >= + UIKIT_GESTURES.pan.hysteresis.value, + }, + }); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (viewport == null) { + return; + } + const measure = () => { + // Ignore zero: the picker can be mounted inside something not yet + // displayed (a closed BottomSheet). + const measured = viewport.clientWidth; + if (measured > 0) { + paneSizeRef.current = measured; + // eslint-disable-next-line @eslint-react/set-state-in-effect -- pane width is a measurement; layout is the only place it exists + setPaneSize(measured); + } + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(viewport); + return () => observer.disconnect(); + }, []); + + // The settle's frames, read for the title until the grid rests. + const settleFrameRef = useRef(null); + const followSettle = useCallback(() => { + if (settleFrameRef.current != null) { + cancelAnimationFrame(settleFrameRef.current); + } + let frames = 0; + const step = () => { + frames += 1; + const width = paneSizeRef.current; + if (frames > 2 && width > 0) { + report( + clampIndex(Math.round(pager.presentation() / width), 0, rowCount - 1), + ); + } + settleFrameRef.current = + frames <= 2 || pager.isSettling ? requestAnimationFrame(step) : null; + }; + settleFrameRef.current = requestAnimationFrame(step); + }, [pager, report, rowCount]); + // Synchronizes with the browser's frame loop: stops reading a settle's + // frames once the grid unmounts. + useEffect( + () => () => { + if (settleFrameRef.current != null) { + cancelAnimationFrame(settleFrameRef.current); + } + }, + [], + ); + + // The title follows the drag: the month nearest the offset, reported as the + // content crosses half a page (UIDatePicker's + // `datePicker.calendar.pager.titleFollows`). The pager's own listener runs + // first, so the position read here is the one this move wrote. + useEffect( + () => + pager.pan.addListener(recognizer => { + const state = recognizer.state; + if (state === 'began' || state === 'changed') { + const width = paneSizeRef.current; + if (width > 0) { + report( + clampIndex( + Math.round(pager.presentation() / width), + 0, + rowCount - 1, + ), + ); + } + } else if (state === 'ended' || state === 'cancelled') { + // Not at the lift: the settle carries the title on, changing it as + // the content crosses half a page, as during the drag. Its first + // two frames report nothing. So the owner's render stays out of + // Mobile Safari's hand-off of the settle to the compositor, as the + // pager's own report does. + followSettle(); + } + }), + [pager, report, rowCount, followSettle], + ); + + // A steered move (an arrow, a key, the wheels, a trackpad step) names its + // month. The settle it interrupts stops reporting, so the title stays on + // the step's month (`datePicker.calendar.chevron.retarget`). + const stopFollowingSettle = useCallback(() => { + if (settleFrameRef.current != null) { + cancelAnimationFrame(settleFrameRef.current); + settleFrameRef.current = null; + } + }, []); + + const scrollToMonth = useCallback( + (monthIndex: number, behavior: ScrollBehavior = 'smooth') => { + stopFollowingSettle(); + const row = clampIndex(monthIndex - minMonthIndex, 0, rowCount - 1); + reportedRowRef.current = row; + // Already the pager's target, as when the month and year wheels close + // on the month shown: nothing moves. A pending far jump here would wait + // for a render `setCenterRow` does not cause. It would then land on the + // next render, a drag crossing half a month, and cancel that drag. + if (row === pager.virtualIndex) { + return; + } + const distance = Math.abs(row - pager.virtualIndex); + if (behavior === 'smooth' && distance <= OVERSCAN) { + pager.goTo(row, {animated: true}); + return; + } + // Beyond the mounted window there is no pane at the target yet: mount + // it first, then move (the layout effect below). A jump that far is + // never worth animating either. + pendingRowRef.current = row; + setCenterRow(row); + }, + [minMonthIndex, rowCount, pager, stopFollowingSettle], + ); + + // Synchronizes the pager's position with the DOM. A far jump moves once + // the render that mounts the target's pane commits, before paint. It has + // no dependencies: it costs a null check and must run on whichever render + // mounts the pending row's pane. + useLayoutEffect(() => { + const row = pendingRowRef.current; + if (row == null) { + return; + } + pendingRowRef.current = null; + pager.goTo(row, {animated: false}); + }); + + useImperativeHandle(handleRef, () => ({scrollToMonth}), [scrollToMonth]); + + // Synchronizes with the viewport's wheel events: one month per horizontal + // wheel gesture (WHEEL_STEP_PX), following the reading direction. + const isWheelRTL = useDirection() === 'rtl'; + useEffect(() => { + const viewport = viewportRef.current; + if (viewport == null) { + return undefined; + } + let travel = 0; + let hasStepped = false; + let quiet: number | null = null; + const onWheel = (event: WheelEvent) => { + const horizontal = event.shiftKey + ? event.deltaX || event.deltaY + : event.deltaX; + const vertical = event.shiftKey ? 0 : event.deltaY; + if (horizontal === 0 || Math.abs(horizontal) < Math.abs(vertical)) { + return; + } + if (quiet != null) { + window.clearTimeout(quiet); + } + quiet = window.setTimeout(() => { + travel = 0; + hasStepped = false; + quiet = null; + }, WHEEL_QUIET_MS); + if (hasStepped) { + return; + } + travel += horizontal; + if (Math.abs(travel) < WHEEL_STEP_PX) { + return; + } + hasStepped = true; + const forward = travel > 0 !== isWheelRTL; + const next = clampIndex( + pager.virtualIndex + (forward ? 1 : -1), + 0, + rowCount - 1, + ); + if (next !== pager.virtualIndex) { + stopFollowingSettle(); + report(next); + pager.goTo(next, {animated: true}); + } + }; + viewport.addEventListener('wheel', onWheel, {passive: true}); + return () => { + viewport.removeEventListener('wheel', onWheel); + if (quiet != null) { + window.clearTimeout(quiet); + } + }; + }, [pager, report, rowCount, isWheelRTL, stopFollowingSettle]); + + // Inside a bottom sheet, the sheet's touch listener skips the drags UIKit + // gives the grid and every move before the decision. The pager's pan uses + // the same rule (above). + useSheetAxisSplit(viewportRef); + + // Move the keyboard focus by whole days (or months) and let the pager + // follow. The vocabulary is the APG grid's, plus PageUp/PageDown for a + // month and Shift with them for a year, as Calendar does. + const moveFocusTo = useCallback( + (from: PlainDate, next: PlainDate) => { + const nextIndex = monthModel.indexOf(next); + if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { + return; + } + shouldRestoreFocusRef.current = true; + setFocusedDate(next); + if (nextIndex !== monthModel.indexOf(from)) { + scrollToMonth(nextIndex, 'smooth'); + onVisibleMonthChangeRef.current(nextIndex); + } + }, + [monthModel, minMonthIndex, maxMonthIndex, scrollToMonth], + ); + + const isRTL = useDirection() === 'rtl'; + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, date: PlainDate) => { + // Column within the displayed week, which is what Home/End move to — + // not the day of the month. + const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; + // The horizontal arrows follow the reading direction (APG grid): under + // RTL the next day sits to the left. + const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; + const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; + const byDays = (days: number) => { + event.preventDefault(); + moveFocusTo(date, plainDateAddDays(date, days)); + }; + const byMonths = (months: number) => { + event.preventDefault(); + moveFocusTo( + date, + calendarShiftMonths(monthModel.calendar, date, months), + ); + }; + const byYears = (years: number) => { + event.preventDefault(); + moveFocusTo(date, calendarShiftYears(monthModel.calendar, date, years)); + }; + switch (event.key) { + case backward: + byDays(-1); + break; + case forward: + byDays(1); + break; + case 'ArrowUp': + byDays(-7); + break; + case 'ArrowDown': + byDays(7); + break; + case 'Home': + byDays(-column); + break; + case 'End': + byDays(6 - column); + break; + case 'PageUp': + if (event.shiftKey) { + byYears(-1); + } else { + byMonths(-1); + } + break; + case 'PageDown': + if (event.shiftKey) { + byYears(1); + } else { + byMonths(1); + } + break; + default: + break; + } + }, + [isRTL, monthModel, moveFocusTo, weekStartsOn], + ); + + // The focused day may sit in a pane mounted only this frame. Focus it once + // it exists, and only after a key press, so a drag never steals focus. + const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); + useEffect(() => { + if (!shouldRestoreFocusRef.current || focusedISO == null) { + return; + } + // A date can be mounted twice: as a real day in its pane and as a + // disabled spill copy in a neighbour. Only the real day may take focus, + // or Enter and Space would select nothing. + const target = viewportRef.current?.querySelector( + `[data-date="${focusedISO}"]:not([data-outside])`, + ); + if (target != null) { + shouldRestoreFocusRef.current = false; + // preventScroll: the viewport does not scroll, and a browser + // scroll-into-view would shift it under the pager's track. + target.focus({preventScroll: true}); + } + }, [focusedISO]); + + const visibleRows = paneWindow(centerRow, rowCount, OVERSCAN); + + // A focus event fires for the programmatic focus above too; only take the + // date when it is actually a different day. + const handleDayFocus = useCallback((date: PlainDate) => { + setFocusedDate(previous => + previous != null && plainDateIsEqual(previous, date) ? previous : date, + ); + }, []); + + return ( +
+
+ {paneSize > 0 && + rowsIn(visibleRows).map(row => ( + + ))} +
+
+ ); +} + +/** + * Memoized, as its panes are. The sheet re-renders on every month/year toggle + * while the calendar's props stay stable. Re-rendering each pane's 42 days + * would be most of the toggle's main-thread work. + */ +export const MonthPager = memo(MonthPagerComponent); + +MonthPager.displayName = 'MonthPager'; + +interface MonthPaneProps { + monthModel: MonthModel; + monthIndex: number; + insetInlineStart: number; + inlineSize: number; + selectedDate: PlainDate | null; + focusedDate: PlainDate | null; + today: PlainDate; + isDateDisabled: (date: PlainDate) => boolean; + weekStartsOn: DayOfWeek; + onSelect: (value: ISODateString) => void; + onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; + onDayFocus: (date: PlainDate) => void; + /** + * Whether this is the month the pager shows. Only its day takes a tab stop: + * a neighbour is mounted off-screen, and Tab into it would scroll the + * clipped viewport under the pager's track. + */ + isCurrent: boolean; +} + +/** + * One month, as a six-row grid. + * + * It has no `role="columnheader"` row: the weekday names are one sticky row + * outside the pager, so they never scroll away. Each day carries its weekday + * in its accessible name instead. + */ +const MonthPane = memo(function MonthPane({ + monthModel, + monthIndex, + insetInlineStart, + inlineSize, + selectedDate, + focusedDate, + today, + isDateDisabled, + weekStartsOn, + onSelect, + onDayKeyDown, + onDayFocus, + isCurrent, +}: MonthPaneProps) { + const locale = useLocale(); + const {calendar} = monthModel; + const month = useMemo( + () => monthModel.monthAt(monthIndex), + [monthModel, monthIndex], + ); + const {weeks} = useCalendarDays({ + ...calendarYearMonthOf(calendar, month), + calendar, + weekStartsOn, + // Always six rows: a variable grid would make pane heights differ, and + // with them every snap offset below this month. + hasVariableRowCount: false, + }); + + const monthLabel = formatCalendarMonthLabel(calendar, month, locale); + + // One day per pane is tab-reachable, so Tab moves through the picker, not + // through 42 buttons. It is the focused day, else the selection, else the + // first of the month. + const tabbableISO = (() => { + if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { + return plainDateToISO(focusedDate); + } + if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { + return plainDateToISO(selectedDate); + } + return plainDateToISO(month.start); + })(); + + return ( +
+ {weeks.map(week => ( +
+ {week.map(day => { + // A spilled day is context, not a choice, as in Calendar's + // `effectivelyDisabled`. So a date borrowed from a neighbouring + // month carries no ring or puck in the pane that only shows it. + const isDisabled = day.isOutside || isDateDisabled(day.date); + const isSelected = + !day.isOutside && + selectedDate != null && + plainDateIsEqual(day.date, selectedDate); + const isToday = !day.isOutside && plainDateIsEqual(day.date, today); + + return ( +
+ +
+ ); + })} +
+ ))} +
+ ); +}); + +MonthPane.displayName = 'MonthPane'; diff --git a/packages/core/src/DateInput/MonthScroller.tsx b/packages/core/src/DateInput/MonthScroller.tsx index 29c6dac321d05..ffa56188b05e8 100644 --- a/packages/core/src/DateInput/MonthScroller.tsx +++ b/packages/core/src/DateInput/MonthScroller.tsx @@ -58,6 +58,7 @@ import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; import { plainDateAddDays, plainDateDayOfWeek, + plainDateDiffDays, plainDateFormatDayNumber, plainDateIsEqual, plainDateToISO, @@ -69,6 +70,8 @@ import { type ReducedMotionDip, } from '../motion/reducedMotion'; import { + calendarShiftMonths, + calendarShiftYears, calendarYearMonthOf, formatCalendarDate, formatCalendarMonthLabel, @@ -280,11 +283,13 @@ const dynamic = stylex.create({ }), }); +/** The handle of MonthScroller and of MonthPager, its touch counterpart. */ export interface MonthScrollerHandle { /** Bring a month to rest at the top of the scrollport. */ scrollToMonth: (monthIndex: number, behavior?: ScrollBehavior) => void; } +/** The props of MonthScroller and of MonthPager, its touch counterpart. */ export interface MonthScrollerProps { /** Imperative handle for programmatic navigation (Today, the wheels). */ handleRef?: React.RefObject; @@ -764,7 +769,8 @@ export function MonthScroller({ }, [paneSize, rowCount, minMonthIndex, isRTL, recenter]); // Move the keyboard focus by whole days and let the scroller follow. Paging - // by month is PageUp/PageDown; everything else is the APG grid vocabulary. + // by month is PageUp/PageDown (a year with Shift); everything else is the + // APG grid vocabulary. const moveFocus = useCallback( (from: PlainDate, deltaDays: number) => { const next = plainDateAddDays(from, deltaDays); @@ -776,6 +782,9 @@ export function MonthScroller({ setFocusedDate(next); if (nextIndex !== monthModel.indexOf(from)) { scrollToMonth(nextIndex, 'smooth'); + // Report the month here, as MonthPager does. The scroll handler drops + // every report while a steered scroll runs, its arrival included. + onVisibleMonthChangeRef.current(nextIndex); } }, [monthModel, minMonthIndex, maxMonthIndex, scrollToMonth], @@ -815,11 +824,23 @@ export function MonthScroller({ event.preventDefault(); moveFocus(date, 6 - column); break; + // A month (a year with Shift), as Calendar does: the same day in the + // month before or after, or that month's last day. + case 'PageUp': + case 'PageDown': { + event.preventDefault(); + const step = event.key === 'PageDown' ? 1 : -1; + const next = event.shiftKey + ? calendarShiftYears(monthModel.calendar, date, step) + : calendarShiftMonths(monthModel.calendar, date, step); + moveFocus(date, plainDateDiffDays(date, next)); + break; + } default: break; } }, - [isRTL, moveFocus, weekStartsOn], + [isRTL, monthModel, moveFocus, weekStartsOn], ); // The focused day may have been in an unmounted pane a frame ago; focus it diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index afc594f09a039..7a7a88a9fd8d0 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -124,8 +124,9 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {normalizeDayOfWeek} from '../utils/dateTypes'; import {getWeekdayHeaders} from '../Calendar/getWeekdayHeaderNames'; -import {useTouchPresentation} from '../utils/touchPresentation'; +import {MonthPager} from './datePickerTouch'; import {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; +import {useTouchPresentation} from '../utils/touchPresentation'; import {MonthYearWheels} from './MonthYearWheels'; import { DEFAULT_MONTH_REACH, @@ -684,6 +685,8 @@ export function TouchDateField({ isSheetOpen ? sheetBodyRef : inputRef, ); const [isWheelOpen, setIsWheelOpen] = useState(false); + const {isTouch} = useTouchPresentation(); + const MonthGrid = isTouch ? MonthPager : MonthScroller; const scrollerHandleRef = useRef(null); const previousArrowRef = useRef(null); const nextArrowRef = useRef(null); @@ -959,7 +962,6 @@ export function TouchDateField({ // wheels close it is already resting on the new month. Under a finger, // UIDatePicker moves a chosen date with these wheels // (datePicker.calendar.monthYearWheels.changesDate). - const {isTouch} = useTouchPresentation(); const handleWheelChange = useCallback( (next: number) => { setMonthIndex(next); @@ -1207,7 +1209,7 @@ export function TouchDateField({ styles.panelBeneath, isWheelOpen && styles.panelBeneathHidden, )}> - + t >= STEP_MS ? 1 : 0.5 - 0.5 * Math.cos((Math.PI * Math.max(t, 0)) / STEP_MS); + +interface OpenCalendar { + driver: TouchDriver; + sheet: Locator; + viewport: Locator; + track: Locator; + title: Locator; + width: number; + center: {x: number; y: number}; +} + +const STORY = 'core-dateinput--bottom-sheet-calendar'; + +async function openCalendar( + page: Page, + {rtl = false}: {rtl?: boolean} = {}, +): Promise { + if (rtl) { + // The Storybook toolbar's direction global, as the URL sets it. + await page.goto( + `${storybook()}/iframe.html?id=${STORY}&viewMode=story&globals=direction:rtl`, + ); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => document.fonts.ready); + } else { + await openStory(page, storybook(), STORY); + } + const driver = await touchDriver(page); + const opener = page.getByRole('button', {name: /calendar/i}).first(); + const box = await opener.boundingBox(); + if (box == null) { + throw new Error('no opener'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const viewport = page.locator('[data-scroller="months"]'); + await expect(viewport).toBeVisible(); + // The sliding surface: the dialog element itself does not move. + const sheet = page + .getByRole('dialog') + .locator('.astryx-bottom-sheet') + .first(); + await waitForRest(sheet); + const rect = await viewport.boundingBox(); + if (rect == null) { + throw new Error('no viewport'); + } + return { + driver, + sheet, + viewport, + track: page.locator('[data-month-track]'), + title: page.locator('[data-title="month-year"]'), + width: rect.width, + center: {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}, + }; +} + +/** The content offset in months: 0 at January in this story. */ +async function monthsIn(viewport: Locator): Promise { + return viewport.evaluate(element => { + const track = element.querySelector('[data-month-track]'); + if (track == null) { + return NaN; + } + const offset = + element.getBoundingClientRect().left - track.getBoundingClientRect().left; + return offset / element.getBoundingClientRect().width; + }); +} + +/** A drag that rests before the lift, so the release velocity is 0. */ +function slowDrag(from: {x: number; y: number}, dx: number, dy = 0) { + return drag( + from, + {x: from.x + dx, y: from.y + dy}, + {durationMs: 500, restMs: 150}, + ); +} + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the calendar keeps the native scroller', async ({page}) => { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); + const viewport = page.locator('[data-scroller="months"]'); + await expect(viewport).toBeVisible(); + expect(await page.locator('[data-month-track]').count()).toBe(0); + expect( + await viewport.evaluate( + element => getComputedStyle(element).scrollSnapType, + ), + ).toContain('mandatory'); + }); +}); + +test('a slow release before half a month returns; past it, the next month', async ({ + page, +}) => { + const {driver, viewport, track, title, width, center} = + await openCalendar(page); + expect(await monthsIn(viewport)).toBeCloseTo(9, 2); + await expect(title).toHaveText('October 2026'); + + // 30 % of a page (finger travel; the pan's hysteresis comes off it). + await driver.play(slowDrag(center, -0.3 * width)); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(9, 2); + await expect(title).toHaveText('October 2026'); + + // 70 %: the next month. + await driver.play(slowDrag(center, -0.7 * width)); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + await expect(title).toHaveText('November 2026'); +}); + +test('after the month and year wheels close, a drag past half a month still pages', async ({ + page, +}) => { + const {driver, viewport, track, title, width, center} = + await openCalendar(page); + const toggle = page.getByRole('button', {name: /choose month and year/i}); + const wheels = page.locator('[data-panel="wheels"]'); + const tapToggle = async () => { + const box = await toggle.boundingBox(); + if (box == null) { + throw new Error('no month and year toggle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapToggle(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + // Closing re-asserts the month shown, which the pager is already on. + await tapToggle(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect + .poll(async () => + wheels.evaluate(element => getComputedStyle(element).opacity), + ) + .toBe('0'); + + await driver.play(slowDrag(center, -0.7 * width)); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + await expect(title).toHaveText('November 2026'); +}); + +test('the title follows the drag, changing at half a month', async ({page}) => { + const {driver, viewport, title, width, center} = await openCalendar(page); + const script = slowDrag(center, -0.7 * width); + const seen: {months: number; title: string}[] = []; + await driver.play(script, { + onEvent: async step => { + if (step.type === 'touchMove') { + seen.push({ + months: await monthsIn(viewport), + title: (await title.textContent()) ?? '', + }); + } + }, + }); + const before = seen.filter(s => s.months < 9.45); + const after = seen.filter(s => s.months > 9.55); + expect(before.length).toBeGreaterThan(0); + expect(after.length).toBeGreaterThan(0); + // A move is reported a frame later than it is read, so judge each side + // away from the crossing. + expect(before.slice(0, -1).every(s => s.title === 'October 2026')).toBe(true); + expect(after.slice(1).every(s => s.title === 'November 2026')).toBe(true); +}); + +test('a flick released before half a month turns the title as the settle crosses half, not at the lift', async ({ + page, +}) => { + const {driver, track, title, center, width} = await openCalendar(page); + await installTimerGate(page); + // About a fifth of a month, fast: the release pages, short of half. + await driver.play( + flick(center, { + velocity: {x: -0.2 * width * 12.5, y: 0}, + durationMs: 80, + hz: 120, + }), + ); + // Hold the page's frames right after the lift: the title must not have + // changed at the lift itself (UIDatePicker's title follows the content). + await freezeTimers(page); + await page.waitForTimeout(80); + expect(await title.textContent()).toBe('October 2026'); + await thawTimers(page); + await waitForRest(track); + await expect(title).toHaveText('November 2026'); +}); + +test('a horizontal trackpad swipe pages one month per gesture', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'Playwright cannot send wheel events to mobile WebKit', + ); + const {track, title, center} = await openCalendar(page); + await page.mouse.move(center.x, center.y); + // One gesture: its momentum keeps sending wheel events. (The phone's + // device pixel ratio of 3 divides the deltas the page sees.) + for (let i = 0; i < 6; i++) { + await page.mouse.wheel(60, 0); + } + await waitForRest(track); + await expect(title).toHaveText('November 2026'); + // A new gesture, after a pause, pages again. + await page.waitForTimeout(300); + await page.mouse.wheel(150, 0); + await waitForRest(track); + await expect(title).toHaveText('December 2026'); +}); + +test('a flick pages exactly one month', async ({page}) => { + const {driver, viewport, track, title, center} = await openCalendar(page); + await driver.play( + flick(center, {velocity: {x: -2500, y: 0}, durationMs: 80, hz: 120}), + ); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + await expect(title).toHaveText('November 2026'); +}); + +test('the arrow names the month at the tap and scrolls on the 0.3 s sinusoid', async ({ + page, +}) => { + const {driver, viewport, track, title, width} = await openCalendar(page); + await installTimerGate(page); + const next = page.getByRole('button', {name: 'Next month'}); + const box = await next.boundingBox(); + if (box == null) { + throw new Error('no arrow'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect(title).toHaveText('November 2026'); + await waitForAnimation(track); + await freezeTimers(page); + await holdAnimations(page, track); + const samples = curveOf( + await sampleCurve(track, timeline(STEP_MS, 1000 / 60)), + 'translateX', + ); + const start = samples[0].value; + const end = samples[samples.length - 1].value; + expect(Math.abs(end - start)).toBeCloseTo(width, 0); + const progress = samples.map(({t, value}) => ({ + t, + value: (value - start) / (end - start), + })); + const fit = compareCurve(progress, sinusoid, 0.02); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + expect(fit.pass, JSON.stringify(fit.worst)).toBe(true); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); +}); + +/** Taps a header arrow through the touch driver. */ +async function tapArrow( + page: Page, + driver: TouchDriver, + name: 'Next month' | 'Previous month', +) { + const box = await page.getByRole('button', {name}).boundingBox(); + if (box == null) { + throw new Error(`no ${name} arrow`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); +} + +test('an arrow tapped right after a release keeps the title on the month it named', async ({ + page, +}) => { + // The release settles towards November; Next, tapped before that settle + // ends, names December. The title goes October, November, December and + // stays, instead of falling back to November while the content is still + // nearest it. + const {driver, title, track, center} = await openCalendar(page); + await title.evaluate(element => { + const seen: string[] = [element.textContent ?? '']; + (window as unknown as {titles: string[]}).titles = seen; + new MutationObserver(() => { + const text = element.textContent ?? ''; + if (seen[seen.length - 1] !== text) { + seen.push(text); + } + }).observe(element, {subtree: true, characterData: true, childList: true}); + }); + await driver.play( + drag(center, {x: center.x - 260, y: center.y}, {durationMs: 200}), + ); + await tapArrow(page, driver, 'Next month'); + await waitForRest(track); + const titles = await page.evaluate( + () => (window as unknown as {titles: string[]}).titles, + ); + const order = ['October', 'November', 'December']; + const months = titles.map(text => + order.findIndex(month => text.includes(month)), + ); + expect(months.at(-1), titles.join(' → ')).toBe(2); + for (let index = 1; index < months.length; index += 1) { + expect(months[index], titles.join(' → ')).toBeGreaterThanOrEqual( + months[index - 1], + ); + } +}); + +/** The track's farthest offset in months, logged every frame from now. */ +async function startOffsetLog(viewport: Locator) { + await viewport.evaluate(element => { + const log: number[] = []; + (window as unknown as {offsets: number[]}).offsets = log; + const track = element.querySelector('[data-month-track]'); + const t0 = performance.now(); + const tick = () => { + if (track != null) { + log.push( + (element.getBoundingClientRect().left - + track.getBoundingClientRect().left) / + element.getBoundingClientRect().width, + ); + } + if (performance.now() - t0 < 2000) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); +} + +test('past the range’s last and first months the calendar rubber-bands, then returns', async ({ + page, +}) => { + const {driver, viewport, track, title, width, center} = + await openCalendar(page); + // October to December, the story's last month. + for (const month of ['November 2026', 'December 2026']) { + await tapArrow(page, driver, 'Next month'); + await expect(title).toHaveText(month); + await waitForRest(track); + } + expect(await monthsIn(viewport)).toBeCloseTo(11, 2); + await startOffsetLog(viewport); + await driver.play(slowDrag(center, -120)); + await waitForRest(track); + let offsets = await page.evaluate( + () => (window as unknown as {offsets: number[]}).offsets, + ); + const past = (Math.max(...offsets) - 11) * width; + // It gives under the finger, less than the finger moved, then comes back. + expect(past).toBeGreaterThan(5); + expect(past).toBeLessThan(0.6 * 120); + expect(await monthsIn(viewport)).toBeCloseTo(11, 2); + await expect(title).toHaveText('December 2026'); + + // January, the first month, by PageUp from a focused day (a year back + // would leave the range). + const day = page.getByRole('button', {name: /December 15, 2026/}); + const dayBox = await day.boundingBox(); + if (dayBox == null) { + throw new Error('no day'); + } + await driver.tap({ + x: dayBox.x + dayBox.width / 2, + y: dayBox.y + dayBox.height / 2, + }); + for (let month = 0; month < 11; month += 1) { + await page.keyboard.press('PageUp'); + } + await expect(title).toHaveText('January 2026'); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(0, 2); + await startOffsetLog(viewport); + await driver.play(slowDrag(center, 120)); + await waitForRest(track); + offsets = await page.evaluate( + () => (window as unknown as {offsets: number[]}).offsets, + ); + const before = -Math.min(...offsets) * width; + expect(before).toBeGreaterThan(5); + expect(before).toBeLessThan(0.6 * 120); + expect(await monthsIn(viewport)).toBeCloseTo(0, 2); + await expect(title).toHaveText('January 2026'); +}); + +test('in RTL a drag to the right pages to the next month, and the arrows follow the reading direction', async ({ + page, +}) => { + const {driver, track, title, width, center} = await openCalendar(page, { + rtl: true, + }); + await expect(page.locator('[dir="rtl"]').first()).toBeAttached(); + await expect(title).toHaveText('October 2026'); + // 70 % of a page toward the inline start's opposite: the next month. + await driver.play(slowDrag(center, 0.7 * width)); + await waitForRest(track); + await expect(title).toHaveText('November 2026'); + // And back the other way. + await driver.play(slowDrag(center, -0.7 * width)); + await waitForRest(track); + await expect(title).toHaveText('October 2026'); + await tapArrow(page, driver, 'Next month'); + await expect(title).toHaveText('November 2026'); + await waitForRest(track); +}); + +/** + * Every running animation under `root`: its animated properties and its + * active length, when its last keyframe change lands. An animation may hold + * its end value for the rest of its span. + */ +async function runningAnimations(locator: Locator) { + return locator.evaluate(root => + root.getAnimations({subtree: true}).map(animation => { + const effect = animation.effect as KeyframeEffect; + const frames = effect.getKeyframes(); + const properties = [ + ...new Set( + frames.flatMap(frame => + Object.keys(frame).filter( + key => + !['offset', 'easing', 'composite', 'computedOffset'].includes( + key, + ), + ), + ), + ), + ]; + let lastChange = 0; + for (let index = 1; index < frames.length; index += 1) { + if ( + properties.some( + property => frames[index][property] !== frames[index - 1][property], + ) + ) { + lastChange = frames[index].computedOffset ?? 0; + } + } + return { + properties, + duration: lastChange * Number(effect.getTiming().duration), + }; + }), + ); +} + +test('Reduced Motion: a release and the arrow step change the month through an opacity dip, without travel', async ({ + page, +}) => { + // design:reduced-motion DR2: a transition a touch release or a tap starts + // changes opacity only, in at most 200 ms. The settle and the step come + // from usePager's animator default (C23's #8146 on F1's #8076). + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, viewport, title, width, center} = await openCalendar(page); + await driver.play(slowDrag(center, -0.7 * width)); + for (const animation of await runningAnimations(viewport)) { + expect(animation.properties).toEqual(['opacity']); + expect(animation.duration).toBeLessThanOrEqual(200); + } + await expect(title).toHaveText('November 2026'); + await page.waitForTimeout(400); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + await tapArrow(page, driver, 'Next month'); + const step = await runningAnimations(viewport); + expect(step.length).toBeGreaterThan(0); + for (const animation of step) { + expect(animation.properties).toEqual(['opacity']); + expect(animation.duration).toBeLessThanOrEqual(200); + } + await expect(title).toHaveText('December 2026'); + await page.waitForTimeout(400); + expect(await monthsIn(viewport)).toBeCloseTo(11, 2); +}); + +test('a second tap mid-scroll goes on to the month after', async ({page}) => { + const {driver, viewport, track, title} = await openCalendar(page); + const next = page.getByRole('button', {name: 'Next month'}); + const box = await next.boundingBox(); + if (box == null) { + throw new Error('no arrow'); + } + const at = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + await driver.tap(at); + await page.waitForTimeout(120); + await driver.tap(at); + await expect(title).toHaveText('December 2026'); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(11, 2); +}); + +test('the arrow step keeps advancing every frame through a blocked main thread', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName === 'webkit', + "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so a 0.3 s motion cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", + ); + await openCalendar(page); + // The incoming month, whose chip shows as soon as its edge slides in. + const incoming = page.getByRole('grid', {name: 'November 2026'}); + await markElement(incoming, {inset: 8}); + const cast = await startScreencast(page); + await page.waitForTimeout(300); + // The page schedules the tap and an 800 ms block 90 ms in. A harness round + // trip would eat most of the 0.3 s motion. + const started = await page.evaluate(() => { + document + .querySelector('button[aria-label="Next month"]') + ?.click(); + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 800; + while (performance.now() < end) { + // Busy. + } + (window as unknown as {busy: unknown}).busy = { + from, + to: performance.timeOrigin + performance.now(), + }; + }, 90); + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(1200); + const busy = await page.evaluate( + () => (window as unknown as {busy: {from: number; to: number}}).busy, + ); + const tracked = await trackMarker(page, await cast.stop()); + const spanned = {...busy, to: Math.min(busy.to, started + STEP_MS)}; + expect(spanned.to - spanned.from).toBeGreaterThan(150); + const proof = busyWindowProof(tracked, spanned, { + axis: 'x', + settledWithin: 1, + }); + await writeEvidence( + 'busy-main-thread/month-pager-arrow-step', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + busy, + proof, + frames: tracked.map(f => ({ + t: +(f.t - started).toFixed(1), + x: f.box?.x ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'busy-main-thread', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); +}); + +/** + * UIKit's split in a sheet (motion/MEASUREMENTS.md M-DP01). A drag on the + * grid is decided on the first move where either axis travels 10 px. It is + * the grid's if the sideways travel has reached 10 px, else the sheet's. At + * 60 Hz a slow drag steps a few px per move, so its angle decides. A fast + * drag's first move can land 10 px sideways however steep it is. + */ +test('in the sheet, a drag is decided once it has moved 10 px either way: slow 35° and fast 55° drags are the grid’s, a slow 60° drag the sheet’s', async ({ + page, +}) => { + const {driver, sheet, viewport, track, center} = await openCalendar(page); + const sheetTop = async () => + sheet.evaluate(el => el.getBoundingClientRect().y); + const topBefore = await sheetTop(); + const along = (degrees: number, length: number) => ({ + x: center.x - length * Math.cos((degrees * Math.PI) / 180), + y: center.y + length * Math.sin((degrees * Math.PI) / 180), + }); + /** Plays `script`, sampling the sheet's drop and the grid's offset per move. */ + const play = async (script: Parameters[0]) => { + const seen = {sheetDrop: 0, monthsMin: Infinity, monthsMax: -Infinity}; + await driver.play(script, { + onEvent: async step => { + if (step.type === 'touchMove') { + seen.sheetDrop = Math.max( + seen.sheetDrop, + Math.abs((await sheetTop()) - topBefore), + ); + const months = await monthsIn(viewport); + seen.monthsMin = Math.min(seen.monthsMin, months); + seen.monthsMax = Math.max(seen.monthsMax, months); + } + }, + }); + return seen; + }; + + // 35°, slow (4 px steps): the sideways travel reaches 10 px first. The + // grid follows the finger, the sheet stays, and the slow release settles + // back on September. + const shallow = await play( + drag(center, along(35, 145), {durationMs: 500, restMs: 150}), + ); + expect(shallow.sheetDrop).toBeLessThan(1); + expect(shallow.monthsMax - 9).toBeGreaterThan(0.2); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(9, 2); + + // 55°, fast (25 px steps, ~1500 px/s): the first move lands 14 px + // sideways. So the grid takes the drag, the flick pages to October, and + // the sheet never moves. + const fast = await play(drag(center, along(55, 150), {durationMs: 100})); + expect(fast.sheetDrop).toBeLessThan(1); + await waitForRest(track); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + + // 60°, slow (3.3 px steps): the vertical travel reaches 10 px first, so + // the sheet takes the drag and the grid never moves. + const steep = await play( + drag(center, along(60, 120), {durationMs: 600, restMs: 150}), + ); + expect(steep.sheetDrop).toBeGreaterThan(20); + expect(steep.monthsMax - steep.monthsMin).toBeLessThan(0.01); + expect(await monthsIn(viewport)).toBeCloseTo(10, 2); +}); + +test( + 'storyboard: the arrow step', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, viewport, track} = await openCalendar(page); + await installTimerGate(page); + const next = page.getByRole('button', {name: 'Next month'}); + const box = await next.boundingBox(); + if (box == null) { + throw new Error('no arrow'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await waitForAnimation(track); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'month-pager-arrow-step', + clip: viewport, + scope: track, + times: timeline(STEP_MS, 1000 / 30), + columns: 6, + cellScale: 0.4, + measure: async () => ({months: +(await monthsIn(viewport)).toFixed(3)}), + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + }, +); + +test( + 'storyboard: a release past half a month settles on the next', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, viewport, track, width, center} = await openCalendar(page); + await installTimerGate(page); + await driver.play(slowDrag(center, -0.7 * width)); + await waitForAnimation(track); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'month-pager-release-settle', + clip: viewport, + scope: track, + columns: 6, + cellScale: 0.4, + measure: async () => ({months: +(await monthsIn(viewport)).toFixed(3)}), + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + }, +); diff --git a/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts index 45787966e3add..bdd0d6859f34c 100644 --- a/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts @@ -2,27 +2,18 @@ /** * @file MonthScroller.interaction.browser.spec.ts - * @input The built "Default" DateInput story, shown in a bottom sheet, - * real touch and mouse input and the interaction harness + * @input The built "Default" DateInput story, shown in a bottom sheet, real + * mouse input and the interaction harness * @output Interaction proofs for the month scroller's arrows under Reduced * Motion (`design:reduced-motion`): with a mouse the month changes at once, - * with no animated property; on a touch screen through a short opacity - * dip, the scroll landing at its middle with no travel + * with no animated property. Under a finger the calendar is MonthPager, + * whose Reduced Motion dip MonthPager.interaction.browser.spec.ts proves. * @position Interaction tests of MonthScroller.tsx * (`*.interaction.browser.spec.ts`, run by `pnpm test:interaction`) */ -import { - expect, - test, - type Locator, - type Page, -} from '@astryxdesign/a11y-spec/playwright'; -import { - openStory, - touchDriver, - withStorybook, -} from '@astryxdesign/interaction-harness'; +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; const storybook = withStorybook(test); const STORY = 'core-dateinput--default'; @@ -31,22 +22,14 @@ const ARGS = {presentation: 'bottom-sheet'}; /** Opens the sheet's calendar and waits for its scroller to come to rest. */ async function openCalendar( page: Page, - input: 'mouse' | 'touch', reducedMotion: 'reduce' | 'no-preference' = 'reduce', ) { await page.emulateMedia({reducedMotion}); await openStory(page, storybook(), STORY, ARGS); - const opener = page.getByRole('button', {name: /calendar/i}).first(); - if (input === 'mouse') { - await opener.click(); - } else { - const box = await opener.boundingBox(); - if (box == null) { - throw new Error('no opener'); - } - const driver = await touchDriver(page); - await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); - } + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); const viewport = page.locator('[data-scroller="months"]'); await expect(viewport).toBeVisible(); // The sheet's own entrance, and the scroller's first snap, settle first. @@ -77,7 +60,7 @@ test.describe('with a mouse', () => { test('without Reduced Motion an arrow still scrolls the month', async ({ page, }) => { - const viewport = await openCalendar(page, 'mouse', 'no-preference'); + const viewport = await openCalendar(page, 'no-preference'); const before = await viewport.evaluate(element => element.scrollLeft); const offsets = await viewport.evaluate( async element => @@ -117,7 +100,7 @@ test.describe('with a mouse', () => { test('under Reduced Motion an arrow changes the month at once', async ({ page, }) => { - const viewport = await openCalendar(page, 'mouse'); + const viewport = await openCalendar(page); expect( await page.evaluate(() => matchMedia('(pointer: coarse)').matches), ).toBe(false); @@ -149,119 +132,3 @@ test.describe('with a mouse', () => { expect(new Set(offsets)).toEqual(new Set([after])); }); }); - -/** - * Records, as each is created, the animations made on `target`: what each - * animates, its keyframes' opacity, and its duration in ms. Read at creation, a - * dip is read on a loaded machine too. There, frames come too far apart to show - * a 200 ms dip's darkest frame. - */ -async function recordCreatedRuns(target: Locator): Promise { - await target.evaluate(element => { - const runs: CreatedRun[] = []; - (window as unknown as {createdRuns: CreatedRun[]}).createdRuns = runs; - const original = Element.prototype.animate; - Element.prototype.animate = function ( - this: Element, - ...args: Parameters - ) { - const animation = original.apply(this, args); - if (this === element) { - const effect = animation.effect as KeyframeEffect; - const frames = effect.getKeyframes(); - runs.push({ - properties: [ - ...new Set( - frames.flatMap(frame => - Object.keys(frame).filter( - key => - ![ - 'offset', - 'easing', - 'composite', - 'computedOffset', - ].includes(key), - ), - ), - ), - ], - opacities: frames.map(frame => Number(frame.opacity)), - duration: Number(effect.getTiming().duration), - }); - } - return animation; - }; - }); -} - -type CreatedRun = {properties: string[]; opacities: number[]; duration: number}; - -test('on a touch screen, under Reduced Motion an arrow dips the calendar and changes the month without travel', async ({ - page, -}) => { - const viewport = await openCalendar(page, 'touch'); - expect( - await page.evaluate(() => matchMedia('(pointer: coarse)').matches), - ).toBe(true); - // The dip is read as it is created; the page's frames record where the - // scroller stands, from the tap until the dip is over. - await recordCreatedRuns(viewport); - await viewport.evaluate(element => { - const w = window as unknown as {offsets: number[]; offsetsDone?: boolean}; - w.offsets = []; - const tick = () => { - w.offsets.push(element.scrollLeft); - if (w.offsetsDone !== true) { - requestAnimationFrame(tick); - } - }; - requestAnimationFrame(tick); - }); - const before = await viewport.evaluate(element => element.scrollLeft); - await page.getByRole('button', {name: 'Next month'}).tap(); - await expect - .poll(async () => - page.evaluate( - () => - (window as unknown as {createdRuns: CreatedRun[]}).createdRuns.length, - ), - ) - .toBeGreaterThan(0); - // The dip is over, however long a loaded machine takes to end it. - await expect - .poll(async () => - viewport.evaluate(element => element.getAnimations().length), - ) - .toBe(0); - // design:reduced-motion DR2: one opacity dip, dark at its middle and back - // at its end, in at most the cross-fade's 200 ms. It plays as two halves, - // so the month changes between them. - const runs = await page.evaluate( - () => (window as unknown as {createdRuns: CreatedRun[]}).createdRuns, - ); - expect(runs).toHaveLength(2); - const [fadeOut, fadeIn] = runs; - expect(fadeOut.properties).toEqual(['opacity']); - expect(fadeIn.properties).toEqual(['opacity']); - expect(fadeOut.opacities).toEqual([1, 0]); - expect(fadeIn.opacities).toEqual([0, 1]); - expect(fadeOut.duration + fadeIn.duration).toBeLessThanOrEqual(200); - // The month changes with no travel: one jump from the old offset to the - // new one, and the scroller stays there. - const after = await viewport.evaluate(element => element.scrollLeft); - expect(after).not.toBe(before); - const offsets = await page.evaluate(() => { - const w = window as unknown as {offsets: number[]; offsetsDone?: boolean}; - w.offsetsDone = true; - return w.offsets; - }); - expect( - offsets.every(left => left === before || left === after), - `the offsets each frame showed: ${offsets.join(', ')}`, - ).toBe(true); - expect( - await viewport.evaluate(element => - Number(getComputedStyle(element).opacity), - ), - ).toBe(1); -}); diff --git a/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts index 9424de1610e94..9228331cc67f3 100644 --- a/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts @@ -8,9 +8,8 @@ * @output Interaction proofs for the month scroller's steps: after the month * and year wheels close, a trackpad scroll still pages to the next month and * Reset from four months on still rests on today's month; an arrow step - * through a stalled frame moves exactly one month; under a finger, a swipe - * that crosses the middle of a month as it lifts keeps scroll snapping on - * until it rests on the next month. + * through a stalled frame moves exactly one month. Under a primary finger + * the calendar is MonthPager (MonthPager.interaction.browser.spec.ts). * (A second arrow step late in the first one's scroll, which WebKit cuts * short, is proven in MonthScrollerSettle.test.tsx: a loaded page's frames * are too far apart to aim a click at a share of a 300 ms scroll.) @@ -26,9 +25,7 @@ import { } from '@astryxdesign/a11y-spec/playwright'; import { blockMainThread, - drag, openStory, - touchDriver, withStorybook, } from '@astryxdesign/interaction-harness'; @@ -277,80 +274,3 @@ test('after the month and year wheels close, Reset from four months on rests on expect(await monthAtRest(viewport)).toBe(today); await expect(toggle).toHaveAccessibleName(new RegExp(today)); }); - -/** The scroller's snapping pauses and its rest after the lift (page clock, ms). */ -interface SnapRecord { - pauses: number[]; - liftedAt: number | null; - restAt: number | null; -} - -test.describe('under a finger', () => { - test.use({hasTouch: true, isMobile: true}); - - /** - * A swipe whose last move crosses the middle of the month makes the - * scroller re-center as the finger lifts. The finger drives that scroll, so - * snapping stays on until the scroll comes to rest (turning it off drops the - * snap target iOS tracks for the gesture, and the swipe coasted past the - * month), and the swipe comes to rest on the next month. - * - * A re-center that commits only after the rest may still pause snapping: - * that is the stalled-frame protection, and at rest it moves nothing. - * WebKit's injected touches drive no native scroll, so its scroll is the - * glide after the lift, and under load the glide's last scroll event and its - * `scrollend` land in one frame: the re-center then commits after the rest. - */ - test('a swipe that crosses the middle of a month as it lifts keeps snapping on until it rests on the next month', async ({ - page, - }) => { - const viewport = await openCalendar(page); - const start = await monthAtRest(viewport); - const box = await viewport.boundingBox(); - if (box == null) { - throw new Error('no month scroller'); - } - // Each pause of the scroller's snapping, and the first time its scroll - // comes to rest after the lift, both on the page's clock. - await viewport.evaluate(element => { - const record: SnapRecord = {pauses: [], liftedAt: null, restAt: null}; - (window as unknown as {snapRecord: SnapRecord}).snapRecord = record; - new MutationObserver(() => { - if ((element as HTMLElement).style.scrollSnapType === 'none') { - record.pauses.push(performance.now()); - } - }).observe(element, {attributes: true, attributeFilter: ['style']}); - window.addEventListener( - 'touchend', - () => { - record.liftedAt ??= performance.now(); - }, - {capture: true, passive: true}, - ); - element.addEventListener('scrollend', () => { - if (record.liftedAt != null) { - record.restAt ??= performance.now(); - } - }); - }); - const driver = await touchDriver(page); - const y = box.y + box.height / 2; - const from = {x: box.x + box.width * 0.8, y}; - // 55 % of a month toward the next one, the middle crossed by the last - // move, lifting a quarter frame after it. - await driver.play( - drag(from, {x: from.x - box.width * 0.55, y}, {durationMs: 300}), - ); - await waitForRestOnMonth(viewport); - const rest = await monthAtRest(viewport); - expect(rest).not.toBe(''); - expect(rest).not.toBe(start); - const {pauses, restAt} = await page.evaluate( - () => (window as unknown as {snapRecord: SnapRecord}).snapRecord, - ); - // The scroll came to rest after the lift, and snapping stayed on until it - // did. - expect(restAt).not.toBeNull(); - expect(pauses.filter(at => at < (restAt ?? 0))).toEqual([]); - }); -}); diff --git a/packages/core/src/DateInput/datePickerTouch.ts b/packages/core/src/DateInput/datePickerTouch.ts index 8cfcf73839865..e12c800f863ae 100644 --- a/packages/core/src/DateInput/datePickerTouch.ts +++ b/packages/core/src/DateInput/datePickerTouch.ts @@ -4,12 +4,14 @@ * @file datePickerTouch.ts * @input The date and time pickers' touch-only pieces * @output Re-exports what the date and time sheets use only under a finger: - * TimeWheels + * TimeWheels and MonthPager * @position The pickers' touch layer. The sheets import touch behaviour from * here only, so a desktop build can replace this one module. * - * SYNC: When modified, update TouchTimeField.tsx and TouchDateTimeField.tsx. + * SYNC: When modified, update TouchTimeField.tsx, TouchDateTimeField.tsx and + * TouchDateField.tsx. */ export {TimeWheels} from '../TimeInput/TimeWheels'; export type {TimeWheelsProps} from '../TimeInput/TimeWheels'; +export {MonthPager} from './MonthPager'; diff --git a/packages/core/src/DateInput/useSheetAxisSplit.ts b/packages/core/src/DateInput/useSheetAxisSplit.ts new file mode 100644 index 0000000000000..f6c1957f0701b --- /dev/null +++ b/packages/core/src/DateInput/useSheetAxisSplit.ts @@ -0,0 +1,134 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useSheetAxisSplit.ts + * @input A horizontally paging element's ref + * @output useSheetAxisSplit: keeps a bottom sheet's touch and pointer + * listeners off the drags that belong to the element + * @position Internal behavior hook, used by MonthPager.tsx + * + * A JavaScript pan raises no `pointercancel`, so without this the sheet would + * take a sideways drag on the calendar. The split is UIDatePicker's in a sheet. + * + * SYNC: When modified, update DateInputTouch.test.tsx. + */ + +import {useEffect} from 'react'; + +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; + +/** + * The travel, on either axis, at which a drag on the grid is decided. UIKit + * begins both the sheet's pan and the grid's at the pan's hysteresis. + * + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline` in a + * medium-detent sheet, 100 pt drags from the grid at 45°, 47.5°, 50°, + * 52.5° and 55°, slow and fast, three each, logged per touch sample + * (motion/MEASUREMENTS.md M-DP01), Oct 25 2026: every drag was decided on + * the first sample where |dx| or |dy| reached 10 pt; the grid took it when + * |dx| had (first samples (10.0, 11.0), (10.7, 12.7)) and the sheet when + * only |dy| had ((9.7, 11.3), (8.0, 10.3)) + */ +const DECISION_TRAVEL = UIKIT_GESTURES.pan.hysteresis.value; + +/** + * Keeps the sheet off the drags on `ref`'s element that UIKit gives the grid, + * and off every move before the decision. + * + * A drag is decided on the first move where either axis travels + * `DECISION_TRAVEL`. It is the element's if the sideways travel has reached + * it, else the sheet's. The moves before the decision reach neither, so the + * sheet's own 8 px promotion cannot start first. + * + * @example + * ``` + * useSheetAxisSplit(viewportRef); + * ``` + */ +export function useSheetAxisSplit( + ref: React.RefObject, + {isEnabled = true}: {isEnabled?: boolean} = {}, +): void { + // Synchronizes with the element's touch and pointer events. + useEffect(() => { + const element = ref.current; + if (element == null || !isEnabled) { + return undefined; + } + // null while the direction is still undecided. + let claimed: boolean | null = null; + let originX = 0; + let originY = 0; + + const begin = (x: number, y: number) => { + originX = x; + originY = y; + claimed = null; + }; + + /** + * Whether a move at (x, y) is kept from the sheet: until the decision, + * and after it when the drag is the element's. Decides once. + */ + const isKept = (x: number, y: number): boolean => { + if (claimed == null) { + const dx = Math.abs(x - originX); + const dy = Math.abs(y - originY); + if (Math.max(dx, dy) < DECISION_TRAVEL) { + return true; + } + claimed = dx >= DECISION_TRAVEL; + } + return claimed; + }; + + const onTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + begin(touch?.clientX ?? 0, touch?.clientY ?? 0); + }; + const onTouchMove = (event: TouchEvent) => { + const touch = event.touches[0]; + if (touch != null && isKept(touch.clientX, touch.clientY)) { + event.stopPropagation(); + } + }; + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType === 'touch' && event.isPrimary) { + begin(event.clientX, event.clientY); + } + }; + const onPointerMove = (event: PointerEvent) => { + if ( + event.pointerType === 'touch' && + event.isPrimary && + isKept(event.clientX, event.clientY) + ) { + event.stopPropagation(); + } + }; + // The end of a gesture carries no interpretation: it propagates, and + // resets the sheet's bookkeeping. + const onEnd = () => { + claimed = null; + }; + + // In the bubble phase, these run before the sheet's listener on its body. + // They are passive, so nothing here cancels a default action. + element.addEventListener('pointerdown', onPointerDown, {passive: true}); + element.addEventListener('pointermove', onPointerMove, {passive: true}); + element.addEventListener('touchstart', onTouchStart, {passive: true}); + element.addEventListener('touchmove', onTouchMove, {passive: true}); + element.addEventListener('touchend', onEnd, {passive: true}); + element.addEventListener('touchcancel', onEnd, {passive: true}); + return () => { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + element.removeEventListener('touchstart', onTouchStart); + element.removeEventListener('touchmove', onTouchMove); + element.removeEventListener('touchend', onEnd); + element.removeEventListener('touchcancel', onEnd); + }; + }, [ref, isEnabled]); +} diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index 866ceebcbb80f..f7b0ad5780643 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -101,7 +101,7 @@ import { } from '../DateInput/MonthScroller'; import {MonthYearWheels} from '../DateInput/MonthYearWheels'; import {Wheel, type WheelOption} from '../DateInput/Wheel'; -import {TimeWheels} from '../DateInput/datePickerTouch'; +import {MonthPager, TimeWheels} from '../DateInput/datePickerTouch'; import { hour12From24, hour24From12, @@ -562,6 +562,7 @@ export function TouchDateTimeField({ const [activePanel, setActivePanel] = useState('date'); const [isWheelOpen, setIsWheelOpen] = useState(false); const {isTouch} = useTouchPresentation(); + const MonthGrid = isTouch ? MonthPager : MonthScroller; const [draftTime, setDraftTime] = useState(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1084,7 +1085,7 @@ export function TouchDateTimeField({ ))} - Date: Thu, 5 Nov 2026 01:30:54 -0800 Subject: [PATCH 02/40] refactor(DateInput): the month pager's sheet split is a mode of useOwnScrollGesture useSheetAxisSplit kept a bottom sheet off the month pager's drags beside useOwnScrollGesture, which does that job for the wheels and the month scroller. Its rule, UIKit's 10 px decision with touch pointers held, is now useOwnScrollGesture's 'pager' mode, and the separate hook goes. --- packages/core/src/DateInput/MonthPager.tsx | 6 +- .../DateInput/useOwnScrollGesture.test.tsx | 125 ++++++++++++++++ .../core/src/DateInput/useOwnScrollGesture.ts | 139 +++++++++++++----- .../core/src/DateInput/useSheetAxisSplit.ts | 134 ----------------- 4 files changed, 227 insertions(+), 177 deletions(-) create mode 100644 packages/core/src/DateInput/useOwnScrollGesture.test.tsx delete mode 100644 packages/core/src/DateInput/useSheetAxisSplit.ts diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 86b6ce6795c58..35674f4d49fb0 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -61,7 +61,7 @@ import { } from '../utils/calendarSystem'; import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; import type {MonthScrollerProps} from './MonthScroller'; -import {useSheetAxisSplit} from './useSheetAxisSplit'; +import {useOwnScrollGesture} from './useOwnScrollGesture'; import {type MonthModel, clampIndex, paneWindow, rowsIn} from './monthGeometry'; const DAY_SIZE = dateInputTouchSizes.daySize; @@ -317,7 +317,7 @@ function MonthPagerComponent({ // The settle has come to rest: whatever the frames showed, the title // names the month the grid rests on. onSettle: index => report(index), - // UIKit's split in a sheet (useSheetAxisSplit, MEASUREMENTS.md M-DP01). + // UIKit's split in a sheet (useOwnScrollGesture's 'pager', MEASUREMENTS.md M-DP01). // The pan begins on the first move where either axis travels its // hysteresis, and only if the sideways travel has reached it. Like every // pan, it claims its touch only when it begins, on the move the sheet's @@ -523,7 +523,7 @@ function MonthPagerComponent({ // Inside a bottom sheet, the sheet's touch listener skips the drags UIKit // gives the grid and every move before the decision. The pager's pan uses // the same rule (above). - useSheetAxisSplit(viewportRef); + useOwnScrollGesture(viewportRef, 'pager'); // Move the keyboard focus by whole days (or months) and let the pager // follow. The vocabulary is the APG grid's, plus PageUp/PageDown for a diff --git a/packages/core/src/DateInput/useOwnScrollGesture.test.tsx b/packages/core/src/DateInput/useOwnScrollGesture.test.tsx new file mode 100644 index 0000000000000..797da9a59e133 --- /dev/null +++ b/packages/core/src/DateInput/useOwnScrollGesture.test.tsx @@ -0,0 +1,125 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useOwnScrollGesture.test.tsx + * @input useOwnScrollGesture's `'pager'` mode on an element under the body + * @output Which touch and pointer moves reach the element's ancestors + * @position Unit tests beside useOwnScrollGesture.ts; the sheet cases sit in + * DateInputTouch.test.tsx + */ + +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, describe, expect, it} from 'vitest'; + +import {useOwnScrollGesture} from './useOwnScrollGesture'; + +function Pager() { + const ref = useRef(null); + useOwnScrollGesture(ref, 'pager'); + return
; +} + +/** The event types that reach the body, in order. */ +function watchBody(): {seen: string[]; stop: () => void} { + const seen: string[] = []; + const types = ['touchstart', 'touchmove', 'pointerdown', 'pointermove']; + const listener = (event: Event) => seen.push(event.type); + for (const type of types) { + document.body.addEventListener(type, listener); + } + return { + seen, + stop: () => { + for (const type of types) { + document.body.removeEventListener(type, listener); + } + }, + }; +} + +/** jsdom has no constructible `TouchEvent`; this one carries its lists. */ +function touch(el: Element, type: string, at: {x: number; y: number}) { + const event = new Event(type, {bubbles: true}); + const point = {identifier: 1, clientX: at.x, clientY: at.y, target: el}; + const touches = type === 'touchend' ? [] : [point]; + Object.defineProperties(event, { + changedTouches: {value: [point]}, + touches: {value: touches}, + targetTouches: {value: touches}, + }); + el.dispatchEvent(event); +} + +const ORIGIN = {x: 150, y: 200}; + +/** A touch from `ORIGIN` through `moves` (offsets from it), then the lift. */ +function dragThrough( + el: Element, + moves: ReadonlyArray, +) { + touch(el, 'touchstart', ORIGIN); + for (const [dx, dy] of moves) { + touch(el, 'touchmove', {x: ORIGIN.x + dx, y: ORIGIN.y + dy}); + } + touch(el, 'touchend', ORIGIN); +} + +describe("useOwnScrollGesture's 'pager'", () => { + afterEach(cleanup); + + it('holds a touch’s moves from the sheet until it has travelled 10 px either way', () => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + touch(pager, 'touchstart', ORIGIN); + touch(pager, 'touchmove', {x: ORIGIN.x - 5, y: ORIGIN.y + 6}); + touch(pager, 'touchmove', {x: ORIGIN.x - 7, y: ORIGIN.y + 8.5}); + expect(body.seen).toEqual(['touchstart']); + // The vertical travel reaches 10 px first: the sheet's from this move. + touch(pager, 'touchmove', {x: ORIGIN.x - 8, y: ORIGIN.y + 10.3}); + expect(body.seen).toEqual(['touchstart', 'touchmove']); + body.stop(); + }); + + /** UIKit's own first samples past the threshold, in a sheet. */ + it.each([ + [10, 11, 'pager'], + [10.7, 12.7, 'pager'], + [9.7, 11.3, 'sheet'], + [8, 10.3, 'sheet'], + ] as const)( + 'a first move past 10 px at (%f, %f) makes the drag the %s’s', + (dx, dy, owner) => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + dragThrough(pager, [ + [-dx, dy], + [-dx * 2, dy * 2], + ]); + expect(body.seen.includes('touchmove')).toBe(owner === 'sheet'); + body.stop(); + }, + ); + + it('decides a touch pointer’s moves the same way and passes a mouse’s', () => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + const at = (dx: number, dy: number, pointerType: string) => ({ + pointerId: 1, + pointerType, + isPrimary: true, + clientX: ORIGIN.x + dx, + clientY: ORIGIN.y + dy, + }); + fireEvent.pointerDown(pager, at(0, 0, 'touch')); + fireEvent.pointerMove(pager, at(-5, 6, 'touch')); + fireEvent.pointerMove(pager, at(-12, 4, 'touch')); + expect(body.seen).toEqual(['pointerdown']); + fireEvent.pointerMove(pager, at(-20, 4, 'mouse')); + expect(body.seen).toEqual(['pointerdown', 'pointermove']); + body.stop(); + }); +}); diff --git a/packages/core/src/DateInput/useOwnScrollGesture.ts b/packages/core/src/DateInput/useOwnScrollGesture.ts index 536423f67be09..591d8ddee2b18 100644 --- a/packages/core/src/DateInput/useOwnScrollGesture.ts +++ b/packages/core/src/DateInput/useOwnScrollGesture.ts @@ -6,8 +6,8 @@ * @file useOwnScrollGesture.ts * @input A scroll container ref and which axis it owns * @output Stops touch gestures on that container from reaching an ancestor - * @position Internal behavior hook; consumed by Wheel.tsx, PickerWheel.tsx and - * MonthScroller.tsx + * @position Internal behavior hook; consumed by Wheel.tsx, PickerWheel.tsx, + * MonthScroller.tsx and MonthPager.tsx * * Lets a nested scroller keep the finger that lands on it. * @@ -27,18 +27,19 @@ * `touchmove` cancelled by the sheet, so the calendar did not move at all and * the sheet sprang back. The month simply would not change. * - * ## Two ways to own a gesture + * ## Three ways to own a gesture * - * - `'all'` — the wheels. They scroll vertically, exactly the axis the sheet - * wants, so there is no way to share: the scroller takes every touch that - * lands on it and the sheet is dismissed from the handle, the header or the - * scrim instead. + * - `'all'` — the wheels. They scroll on the sheet's own axis, so they take + * every touch, and the sheet is dismissed from its handle, header or scrim. * * - `'inline'` — the month scroller. It pages sideways, so the two CAN share * by direction: lock the axis on the first significant move, claim the * gesture only when it is horizontal, and let vertical ones through so * swipe-to-dismiss still works on the calendar. * + * - `'pager'` — the month pager, which pans in JavaScript. It splits as UIKit + * does in a sheet ({@link PAGER_DECISION_TRAVEL}), touch pointers included. + * * ## Where the line falls, and the gestures that fall past it * * A thumb swiping sideways travels in an arc, not a line — a "horizontal" @@ -60,9 +61,8 @@ * swipe that cleared {@link SWIPE_DISTANCE} is reported so the caller can page * itself. Native pans keep their momentum and snapping and report nothing. * - * The remaining asymmetry is deliberate and in the direction the scroller - * needs: it is generous about distance too, paging on a 30px flick where the - * sheet asks for ~200px of pull before it dismisses. + * It is generous about distance too: a 30px flick pages, where the sheet asks + * for ~200px of pull before it dismisses. * * ## Why native listeners * @@ -80,6 +80,8 @@ import {useEffect, useRef} from 'react'; +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; + /** * How much a gesture's vertical travel must exceed its horizontal travel * before an `'inline'` scroller gives it up. Above 1, so horizontal wins the @@ -97,7 +99,21 @@ const AXIS_LOCK_SLOP = 6; */ export const SWIPE_DISTANCE = 32; -export type OwnedAxis = 'all' | 'inline'; +/** + * The travel, on either axis, at which a `'pager'` drag is decided. UIKit + * begins both the sheet's pan and the grid's at the pan's hysteresis. + * + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline` in a + * medium-detent sheet, 100 pt drags from the grid at 45°, 47.5°, 50°, + * 52.5° and 55°, slow and fast, three each, logged per touch sample + * (motion/MEASUREMENTS.md M-DP01), Oct 25 2026: every drag was decided on + * the first sample where |dx| or |dy| reached 10 pt; the grid took it when + * |dx| had (first samples (10.0, 11.0), (10.7, 12.7)) and the sheet when + * only |dy| had ((9.7, 11.3), (8.0, 10.3)) + */ +const PAGER_DECISION_TRAVEL = UIKIT_GESTURES.pan.hysteresis.value; + +export type OwnedAxis = 'all' | 'inline' | 'pager'; export type OwnScrollGestureOptions = { /** Skip while the scroller is hidden; a hidden panel keeps its layout box, @@ -133,7 +149,8 @@ export type OwnScrollGestureOptions = { * * @param ref - the scroll container * @param axis - `'all'` to take every touch, `'inline'` to take only the - * horizontal ones and leave vertical drags to the sheet + * horizontal ones and leave vertical drags to the sheet, `'pager'` to split + * as UIKit does in a sheet * @param options - see {@link OwnScrollGestureOptions} */ export function useOwnScrollGesture( @@ -148,8 +165,8 @@ export function useOwnScrollGesture( const onSwipeRef = useRef(onSwipe); onSwipeRef.current = onSwipe; - // Syncs touch listeners on the element with `isEnabled`. They decide which - // gesture owns a horizontal scroll. + // Syncs the element's touch listeners, and a pager's pointer listeners, + // with `isEnabled`. They decide which gesture owns a drag on the element. useEffect(() => { const element = ref.current; if (element == null || !isEnabled) { @@ -166,45 +183,63 @@ export function useOwnScrollGesture( // Sampled per move rather than compared once at the end, because a short // pan that snaps back finishes at the offset it started from. let didPan = false; + // Only an 'inline' scroller reads its offset: the swipe fallback needs it, + // and a pager's JavaScript pan should not force a layout on every move. + const isTrackingPan = axis === 'inline'; - const onStart = (event: TouchEvent) => { - const touch = event.touches[0]; - originX = touch?.clientX ?? 0; - originY = touch?.clientY ?? 0; - lastX = originX; - offsetAtStart = element.scrollLeft; + const begin = (x: number, y: number) => { + originX = x; + originY = y; + lastX = x; + offsetAtStart = isTrackingPan ? element.scrollLeft : 0; didPan = false; // 'all' decides at touchstart, before the sheet's own touchstart - // listener records an armed edge. 'inline' cannot decide yet — a touch - // has no direction until it moves — so it lets touchstart through and - // locks on the first move that clears the slop. + // listener records an armed edge. The others cannot decide yet: a touch + // has no direction until it moves. claimed = axis === 'all' ? true : null; + }; + + /** + * Whether a move to (x, y) is kept from the ancestor. It decides once: + * `'inline'` on the first move past the slop, and `'pager'` on the first + * move that travels its decision distance. A pager holds the moves before. + */ + const isKept = (x: number, y: number): boolean => { + if (claimed == null) { + const dx = Math.abs(x - originX); + const dy = Math.abs(y - originY); + if (axis === 'pager') { + if (Math.max(dx, dy) < PAGER_DECISION_TRAVEL) { + return true; + } + claimed = dx >= PAGER_DECISION_TRAVEL; + } else { + if (Math.max(dx, dy) < AXIS_LOCK_SLOP) { + return false; + } + claimed = dy <= dx * VERTICAL_DOMINANCE; + } + } + return claimed; + }; + + const onStart = (event: TouchEvent) => { + const touch = event.touches[0]; + begin(touch?.clientX ?? 0, touch?.clientY ?? 0); if (claimed) { event.stopPropagation(); } }; const onMove = (event: TouchEvent) => { - if (claimed === false) { - return; - } const touch = event.touches[0]; - if (touch == null) { + if (touch == null || !isKept(touch.clientX, touch.clientY)) { return; } - if (claimed == null) { - const dx = Math.abs(touch.clientX - originX); - const dy = Math.abs(touch.clientY - originY); - if (Math.max(dx, dy) < AXIS_LOCK_SLOP) { - return; - } - claimed = dy <= dx * VERTICAL_DOMINANCE; - if (!claimed) { - return; - } - } lastX = touch.clientX; - didPan ||= Math.abs(element.scrollLeft - offsetAtStart) >= 1; + if (isTrackingPan) { + didPan ||= Math.abs(element.scrollLeft - offsetAtStart) >= 1; + } event.stopPropagation(); }; @@ -214,7 +249,7 @@ export function useOwnScrollGesture( // nothing at all. See the file header. if ( claimed === true && - axis === 'inline' && + isTrackingPan && !didPan && Math.abs(lastX - originX) >= SWIPE_DISTANCE ) { @@ -226,8 +261,24 @@ export function useOwnScrollGesture( claimed = null; }; + // A pager pans in JavaScript, which cancels no pointer, so the sheet would + // also follow its touch pointer. The same decision keeps it. + const isTouchPointer = (event: PointerEvent) => + event.pointerType === 'touch' && event.isPrimary; + const onPointerDown = (event: PointerEvent) => { + if (isTouchPointer(event)) { + begin(event.clientX, event.clientY); + } + }; + const onPointerMove = (event: PointerEvent) => { + if (isTouchPointer(event) && isKept(event.clientX, event.clientY)) { + event.stopPropagation(); + } + }; + const keepPointer = (event: PointerEvent) => event.stopPropagation(); const isKeepingPointer = axis === 'all' && isPointerKept; + const isSplittingPointer = axis === 'pager'; element.addEventListener('touchstart', onStart, {passive: true}); element.addEventListener('touchmove', onMove, {passive: true}); @@ -236,6 +287,10 @@ export function useOwnScrollGesture( if (isKeepingPointer) { element.addEventListener('pointerdown', keepPointer); } + if (isSplittingPointer) { + element.addEventListener('pointerdown', onPointerDown, {passive: true}); + element.addEventListener('pointermove', onPointerMove, {passive: true}); + } return () => { element.removeEventListener('touchstart', onStart); element.removeEventListener('touchmove', onMove); @@ -244,6 +299,10 @@ export function useOwnScrollGesture( if (isKeepingPointer) { element.removeEventListener('pointerdown', keepPointer); } + if (isSplittingPointer) { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + } }; }, [ref, axis, isEnabled, isPointerKept]); } diff --git a/packages/core/src/DateInput/useSheetAxisSplit.ts b/packages/core/src/DateInput/useSheetAxisSplit.ts deleted file mode 100644 index f6c1957f0701b..0000000000000 --- a/packages/core/src/DateInput/useSheetAxisSplit.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file useSheetAxisSplit.ts - * @input A horizontally paging element's ref - * @output useSheetAxisSplit: keeps a bottom sheet's touch and pointer - * listeners off the drags that belong to the element - * @position Internal behavior hook, used by MonthPager.tsx - * - * A JavaScript pan raises no `pointercancel`, so without this the sheet would - * take a sideways drag on the calendar. The split is UIDatePicker's in a sheet. - * - * SYNC: When modified, update DateInputTouch.test.tsx. - */ - -import {useEffect} from 'react'; - -import {UIKIT_GESTURES} from '../motion/uikit/gestures'; - -/** - * The travel, on either axis, at which a drag on the grid is decided. UIKit - * begins both the sheet's pan and the grid's at the pan's hysteresis. - * - * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline` in a - * medium-detent sheet, 100 pt drags from the grid at 45°, 47.5°, 50°, - * 52.5° and 55°, slow and fast, three each, logged per touch sample - * (motion/MEASUREMENTS.md M-DP01), Oct 25 2026: every drag was decided on - * the first sample where |dx| or |dy| reached 10 pt; the grid took it when - * |dx| had (first samples (10.0, 11.0), (10.7, 12.7)) and the sheet when - * only |dy| had ((9.7, 11.3), (8.0, 10.3)) - */ -const DECISION_TRAVEL = UIKIT_GESTURES.pan.hysteresis.value; - -/** - * Keeps the sheet off the drags on `ref`'s element that UIKit gives the grid, - * and off every move before the decision. - * - * A drag is decided on the first move where either axis travels - * `DECISION_TRAVEL`. It is the element's if the sideways travel has reached - * it, else the sheet's. The moves before the decision reach neither, so the - * sheet's own 8 px promotion cannot start first. - * - * @example - * ``` - * useSheetAxisSplit(viewportRef); - * ``` - */ -export function useSheetAxisSplit( - ref: React.RefObject, - {isEnabled = true}: {isEnabled?: boolean} = {}, -): void { - // Synchronizes with the element's touch and pointer events. - useEffect(() => { - const element = ref.current; - if (element == null || !isEnabled) { - return undefined; - } - // null while the direction is still undecided. - let claimed: boolean | null = null; - let originX = 0; - let originY = 0; - - const begin = (x: number, y: number) => { - originX = x; - originY = y; - claimed = null; - }; - - /** - * Whether a move at (x, y) is kept from the sheet: until the decision, - * and after it when the drag is the element's. Decides once. - */ - const isKept = (x: number, y: number): boolean => { - if (claimed == null) { - const dx = Math.abs(x - originX); - const dy = Math.abs(y - originY); - if (Math.max(dx, dy) < DECISION_TRAVEL) { - return true; - } - claimed = dx >= DECISION_TRAVEL; - } - return claimed; - }; - - const onTouchStart = (event: TouchEvent) => { - const touch = event.touches[0]; - begin(touch?.clientX ?? 0, touch?.clientY ?? 0); - }; - const onTouchMove = (event: TouchEvent) => { - const touch = event.touches[0]; - if (touch != null && isKept(touch.clientX, touch.clientY)) { - event.stopPropagation(); - } - }; - const onPointerDown = (event: PointerEvent) => { - if (event.pointerType === 'touch' && event.isPrimary) { - begin(event.clientX, event.clientY); - } - }; - const onPointerMove = (event: PointerEvent) => { - if ( - event.pointerType === 'touch' && - event.isPrimary && - isKept(event.clientX, event.clientY) - ) { - event.stopPropagation(); - } - }; - // The end of a gesture carries no interpretation: it propagates, and - // resets the sheet's bookkeeping. - const onEnd = () => { - claimed = null; - }; - - // In the bubble phase, these run before the sheet's listener on its body. - // They are passive, so nothing here cancels a default action. - element.addEventListener('pointerdown', onPointerDown, {passive: true}); - element.addEventListener('pointermove', onPointerMove, {passive: true}); - element.addEventListener('touchstart', onTouchStart, {passive: true}); - element.addEventListener('touchmove', onTouchMove, {passive: true}); - element.addEventListener('touchend', onEnd, {passive: true}); - element.addEventListener('touchcancel', onEnd, {passive: true}); - return () => { - element.removeEventListener('pointerdown', onPointerDown); - element.removeEventListener('pointermove', onPointerMove); - element.removeEventListener('touchstart', onTouchStart); - element.removeEventListener('touchmove', onTouchMove); - element.removeEventListener('touchend', onEnd); - element.removeEventListener('touchcancel', onEnd); - }; - }, [ref, isEnabled]); -} From 77e8a8ad5018b46fe129ad400ee0ddb48fea8525 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:54 -0800 Subject: [PATCH 03/40] refactor(DateInput): MonthPager's Effects name what they sync, and its pane width uses the shared resize observer Each of MonthPager's Effects now names the system it syncs. Its pane width comes from observeResize instead of its own ResizeObserver, and the owner's callback is read through a ref written in render, with no Effect. --- packages/core/src/DateInput/MonthPager.tsx | 34 ++++++++++------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 35674f4d49fb0..cd63140ffdfb4 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -42,6 +42,7 @@ import { borderVars, } from '../theme/tokens.stylex'; import {focusOutlineStyles} from '../utils/focusOutline.stylex'; +import {observeResize} from '../utils/sharedResizeObserver'; import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; import { plainDateAddDays, @@ -285,10 +286,9 @@ function MonthPagerComponent({ const [focusedDate, setFocusedDate] = useState(null); const shouldRestoreFocusRef = useRef(false); + // Read through a ref, so a caller need not memoize the callback. const onVisibleMonthChangeRef = useRef(onVisibleMonthChange); - useLayoutEffect(() => { - onVisibleMonthChangeRef.current = onVisibleMonthChange; - }); + onVisibleMonthChangeRef.current = onVisibleMonthChange; const report = useCallback( (row: number) => { @@ -317,11 +317,11 @@ function MonthPagerComponent({ // The settle has come to rest: whatever the frames showed, the title // names the month the grid rests on. onSettle: index => report(index), - // UIKit's split in a sheet (useOwnScrollGesture's 'pager', MEASUREMENTS.md M-DP01). - // The pan begins on the first move where either axis travels its - // hysteresis, and only if the sideways travel has reached it. Like every - // pan, it claims its touch only when it begins, on the move the sheet's - // split decides on. + // UIKit's split in a sheet (useOwnScrollGesture's 'pager' mode, + // MEASUREMENTS.md M-DP01). The pan begins on the first move where either + // axis travels its hysteresis, and only if the sideways travel has reached + // it. Like every pan, it claims its touch only when it begins, on the move + // the sheet's split decides on. panOptions: { axisRatio: 0, shouldBegin: recognizer => @@ -330,25 +330,22 @@ function MonthPagerComponent({ }, }); + // Syncs the pane width with the viewport's size, through the shared resize + // observer. It measures once on registration, then on every resize. useLayoutEffect(() => { const viewport = viewportRef.current; if (viewport == null) { return; } - const measure = () => { + return observeResize(viewport, () => { // Ignore zero: the picker can be mounted inside something not yet // displayed (a closed BottomSheet). const measured = viewport.clientWidth; if (measured > 0) { paneSizeRef.current = measured; - // eslint-disable-next-line @eslint-react/set-state-in-effect -- pane width is a measurement; layout is the only place it exists setPaneSize(measured); } - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(viewport); - return () => observer.disconnect(); + }); }, []); // The settle's frames, read for the title until the grid rests. @@ -464,9 +461,9 @@ function MonthPagerComponent({ useImperativeHandle(handleRef, () => ({scrollToMonth}), [scrollToMonth]); + const isWheelRTL = useDirection() === 'rtl'; // Synchronizes with the viewport's wheel events: one month per horizontal // wheel gesture (WHEEL_STEP_PX), following the reading direction. - const isWheelRTL = useDirection() === 'rtl'; useEffect(() => { const viewport = viewportRef.current; if (viewport == null) { @@ -609,9 +606,10 @@ function MonthPagerComponent({ [isRTL, monthModel, moveFocusTo, weekStartsOn], ); - // The focused day may sit in a pane mounted only this frame. Focus it once - // it exists, and only after a key press, so a drag never steals focus. const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); + // Syncs DOM focus with the focused day. The day may sit in a pane mounted + // only this frame: it takes focus once it exists. Only a key press moves + // it, so a drag never steals focus. useEffect(() => { if (!shouldRestoreFocusRef.current || focusedISO == null) { return; From d0da0a14d6e50d9d52faf8e882a38707298f9d6a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:54 -0800 Subject: [PATCH 04/40] fix(DateInput): the month pager follows a trackpad through the shared pager alone MonthPager paged a month after 25 px of horizontal wheel travel, beside usePager's own trackpad pan, so a slow 30 px swipe paged the month. The listener, its constants and their pending measurement go: the pager follows a trackpad's scroll and pages on its release. The Chromium-only wheel test goes too, since Chromium's driven wheels read as a mouse's wheel, which the pager leaves to the page. --- .../calendar-pages-like-uidatepicker.md | 2 +- .../stories/MonthPagerProofs.stories.tsx | 72 +++++++++++++++ .../src/DateInput/DateInputTouch.test.tsx | 65 +++++++++++++ packages/core/src/DateInput/MonthPager.tsx | 70 -------------- .../MonthPager.interaction.browser.spec.ts | 92 +++++++++++++------ packages/core/src/motion/MEASUREMENTS.md | 4 - 6 files changed, 204 insertions(+), 101 deletions(-) create mode 100644 apps/storybook/stories/MonthPagerProofs.stories.tsx diff --git a/.changeset/calendar-pages-like-uidatepicker.md b/.changeset/calendar-pages-like-uidatepicker.md index 181a157f54c86..1c79d4a892d80 100644 --- a/.changeset/calendar-pages-like-uidatepicker.md +++ b/.changeset/calendar-pages-like-uidatepicker.md @@ -8,7 +8,7 @@ - The month title follows the drag and the settle. It changes when the next month is more than half in view. - The arrows scroll the month in 0.3 s. A second tap mid-scroll goes on to the month after. - In the sheet, a drag is decided once it has moved 10 px either way. It pages the calendar if it has moved 10 px sideways by then, and moves the sheet otherwise. -- A horizontal trackpad swipe, or Shift with a mouse wheel, pages one month per gesture. +- A horizontal trackpad swipe moves the calendar with it and pages at the release, like a drag. - With Reduce Motion on, the arrows' step and a swipe's settle cross-fade in 200 ms instead of travelling. With a mouse, the calendar still scrolls natively. With any pointer, PageUp and PageDown now move the keyboard focus by a month, and by a year with Shift. diff --git a/apps/storybook/stories/MonthPagerProofs.stories.tsx b/apps/storybook/stories/MonthPagerProofs.stories.tsx new file mode 100644 index 0000000000000..8d0d79104bb67 --- /dev/null +++ b/apps/storybook/stories/MonthPagerProofs.stories.tsx @@ -0,0 +1,72 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MonthPagerProofs.stories.tsx + * @input Uses React and the internal MonthPager (packages/core/src/DateInput) + * @output The month pager alone, whatever the primary pointer: 2026 from + * March, its shown month in a read-only field + * @position A checked-in reproduction path for the trackpad proofs in + * packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts. + * WebKit's driver sends a trackpad's precise deltas only from a desktop + * context, where the sheets show the mouse calendar instead. + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {useState} from 'react'; +import {MonthPager} from '../../../packages/core/src/DateInput/MonthPager'; +import { + createMonthModel, + fromMonthIndex, + toMonthIndex, +} from '../../../packages/core/src/DateInput/monthGeometry'; + +const FIRST = toMonthIndex(2026, 1); +const LAST = toMonthIndex(2026, 12); +const MARCH = toMonthIndex(2026, 3); +const TODAY = {year: 2026, month: 3, day: 15}; +const GREGORY = createMonthModel('gregory'); + +/** + * The readout takes the theme's text colour on the theme's surface, not the + * browser's white field. + */ +const READOUT_STYLE = {color: 'inherit', backgroundColor: 'transparent'}; + +const meta = { + title: 'Core/MonthPager', + tags: ['no-visual', '!autodocs'], + parameters: {layout: 'centered'}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The pager alone. Its field names the month it shows. */ +export const Pager: Story = { + render: () => { + const [shown, setShown] = useState(MARCH); + const {year, month} = fromMonthIndex(shown); + return ( +
+ + false} + weekStartsOn={0} + onSelect={() => {}} + /> +
+ ); + }, +}; diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 027f226ad9c31..84eb37f789701 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -191,6 +191,21 @@ function withLayout(fn: () => T): T { } } +/** As `withLayout`, around work that waits on timers. */ +async function withLayoutAsync(fn: () => Promise): Promise { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.dataset?.scroller === 'months' ? SCROLLPORT_WIDTH : 0; + }, + }); + try { + await fn(); + } finally { + delete (HTMLElement.prototype as {clientWidth?: unknown}).clientWidth; + } +} + beforeAll(() => { Element.prototype.scrollTo = vi.fn(); // jsdom's implements none of these. @@ -2632,6 +2647,56 @@ describe('DateInput — nested scrollers keep their own touch gesture', () => { ancestor.stop(); }); + /** + * A trackpad's scroll on the calendar: precise deltas, with the legacy + * `wheelDelta` that a trackpad's wheels carry at three times each delta. + */ + const trackpadScroll = (el: Element, dx: number) => { + const event = new WheelEvent('wheel', { + deltaX: dx, + deltaMode: 0, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'wheelDeltaX', {value: -3 * dx}); + Object.defineProperty(event, 'wheelDeltaY', {value: 0}); + el.dispatchEvent(event); + }; + const wait = async (ms: number) => + act(async () => new Promise(resolve => setTimeout(resolve, ms))); + + it('under a finger: a slow 30 px trackpad swipe leaves the calendar on its month', async () => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const title = document.querySelector('[data-title="month-year"]')!; + expect(title).toHaveTextContent('March 2026'); + await withLayoutAsync(async () => { + // Five 6 px steps, 40 ms apart: under a tenth of a page. + for (let i = 0; i < 5; i++) { + act(() => trackpadScroll(scroller, 6)); + await wait(40); + } + // The swipe ends 220 ms after its last wheel, then the grid settles. + await wait(900); + }); + expect(title).toHaveTextContent('March 2026'); + }); + + it('under a finger: a trackpad swipe past half a page pages one month', async () => { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const title = document.querySelector('[data-title="month-year"]')!; + await withLayoutAsync(async () => { + // 40 steps of 6.5 px: 260 px toward the next month. + for (let i = 0; i < 40; i++) { + act(() => trackpadScroll(scroller, 6.5)); + await wait(8); + } + await wait(900); + }); + expect(title).toHaveTextContent('April 2026'); + }); + it('keeps a diagonal drag, because a thumb arcs as it swipes', () => { stubTouchscreenLaptop(); expect(matchMedia('(pointer: coarse)').matches).toBe(false); diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index cd63140ffdfb4..69c0325d5498c 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -74,20 +74,6 @@ const DAY_SIZE = dateInputTouchSizes.daySize; */ const OVERSCAN = 3; -/** - * A horizontal wheel gesture, a trackpad's two-finger swipe or Shift with a - * mouse wheel, pages one month once it travels this far. A gesture ends - * after this long without a wheel event, its momentum included. - * - * @source GUESS — not UIKit values: UIKit's grid follows a trackpad's scroll - * and pages on its release rule, which this step stands in for; 25 px turns - * a month on a short deliberate swipe but not on a nudge, and 120 ms - * outlasts the gap between a swipe's momentum events - * @measure M-DP02 - */ -const WHEEL_STEP_PX = 25; -const WHEEL_QUIET_MS = 120; - const styles = stylex.create({ viewport: { position: 'relative', @@ -461,62 +447,6 @@ function MonthPagerComponent({ useImperativeHandle(handleRef, () => ({scrollToMonth}), [scrollToMonth]); - const isWheelRTL = useDirection() === 'rtl'; - // Synchronizes with the viewport's wheel events: one month per horizontal - // wheel gesture (WHEEL_STEP_PX), following the reading direction. - useEffect(() => { - const viewport = viewportRef.current; - if (viewport == null) { - return undefined; - } - let travel = 0; - let hasStepped = false; - let quiet: number | null = null; - const onWheel = (event: WheelEvent) => { - const horizontal = event.shiftKey - ? event.deltaX || event.deltaY - : event.deltaX; - const vertical = event.shiftKey ? 0 : event.deltaY; - if (horizontal === 0 || Math.abs(horizontal) < Math.abs(vertical)) { - return; - } - if (quiet != null) { - window.clearTimeout(quiet); - } - quiet = window.setTimeout(() => { - travel = 0; - hasStepped = false; - quiet = null; - }, WHEEL_QUIET_MS); - if (hasStepped) { - return; - } - travel += horizontal; - if (Math.abs(travel) < WHEEL_STEP_PX) { - return; - } - hasStepped = true; - const forward = travel > 0 !== isWheelRTL; - const next = clampIndex( - pager.virtualIndex + (forward ? 1 : -1), - 0, - rowCount - 1, - ); - if (next !== pager.virtualIndex) { - stopFollowingSettle(); - report(next); - pager.goTo(next, {animated: true}); - } - }; - viewport.addEventListener('wheel', onWheel, {passive: true}); - return () => { - viewport.removeEventListener('wheel', onWheel); - if (quiet != null) { - window.clearTimeout(quiet); - } - }; - }, [pager, report, rowCount, isWheelRTL, stopFollowingSettle]); - // Inside a bottom sheet, the sheet's touch listener skips the drags UIKit // gives the grid and every move before the decision. The pager's pan uses // the same rule (above). diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 4dea788c4e6b9..065b28d5af469 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -2,8 +2,8 @@ /** * @file MonthPager.interaction.browser.spec.ts - * @input The built "Bottom Sheet Calendar" DateInput story, real touch input - * and the interaction harness + * @input The built "Bottom Sheet Calendar" DateInput story and the MonthPager + * proof story, real touch and trackpad input, and the interaction harness * @output Interaction proofs that the sheet's calendar pages like UIDatePicker * under a finger, in Chromium and WebKit: releases, flicks, the title, the * arrows, the sheet split, the rubber band, RTL and Reduced Motion @@ -38,8 +38,10 @@ import { trackMarker, waitForAnimation, waitForRest, + webInputDriver, withStorybook, writeEvidence, + WHEEL_SKIP, type TouchDriver, } from '@astryxdesign/interaction-harness'; import {paging} from '../../motion/uikit/paging'; @@ -254,30 +256,6 @@ test('a flick released before half a month turns the title as the settle crosses await expect(title).toHaveText('November 2026'); }); -test('a horizontal trackpad swipe pages one month per gesture', async ({ - page, - browserName, -}) => { - test.skip( - browserName === 'webkit', - 'Playwright cannot send wheel events to mobile WebKit', - ); - const {track, title, center} = await openCalendar(page); - await page.mouse.move(center.x, center.y); - // One gesture: its momentum keeps sending wheel events. (The phone's - // device pixel ratio of 3 divides the deltas the page sees.) - for (let i = 0; i < 6; i++) { - await page.mouse.wheel(60, 0); - } - await waitForRest(track); - await expect(title).toHaveText('November 2026'); - // A new gesture, after a pause, pages again. - await page.waitForTimeout(300); - await page.mouse.wheel(150, 0); - await waitForRest(track); - await expect(title).toHaveText('December 2026'); -}); - test('a flick pages exactly one month', async ({page}) => { const {driver, viewport, track, title, center} = await openCalendar(page); await driver.play( @@ -742,3 +720,65 @@ test( await thawTimers(page); }, ); + +test.describe('a trackpad (spec:AST-094 FR43)', () => { + // WebKit's driver sends a trackpad's precise deltas from a desktop context; + // the pager is opened alone there. + test.use({hasTouch: false, isMobile: false}); + const TRACKPAD_IN_WEBKIT = + "Chromium's driver sends each wheel as a mouse wheel's notch; only WebKit's sends a trackpad's precise deltas"; + + async function openPager(page: Page) { + await openStory(page, storybook(), 'core-monthpager--pager'); + const field = page.getByRole('textbox', {name: 'Shown month'}); + await expect(field).toHaveValue('2026-03'); + const viewport = page.locator('[data-scroller="months"]'); + await expect(viewport).toBeVisible(); + const box = await viewport.boundingBox(); + if (box == null) { + throw new Error('no pager'); + } + return { + field, + track: page.locator('[data-month-track]'), + at: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; + } + + test('a slow 30 px trackpad swipe leaves the pager on its month, and the month never changes on the way', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'webkit', TRACKPAD_IN_WEBKIT); + const {field, track, at} = await openPager(page); + const input = await webInputDriver(page); + test.skip(!input.wheels, WHEEL_SKIP); + // Five steps of 6 px, 40 ms apart. + for (let i = 0; i < 5; i++) { + await input.wheel({...at, dx: 6}); + expect(await field.inputValue()).toBe('2026-03'); + await page.waitForTimeout(40); + } + // A trackpad scroll ends 220 ms after its last wheel. + await page.waitForTimeout(300); + await waitForRest(track); + await expect(field).toHaveValue('2026-03'); + }); + + test('a trackpad swipe past half a page pages one month', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'webkit', TRACKPAD_IN_WEBKIT); + const {field, track, at} = await openPager(page); + const input = await webInputDriver(page); + test.skip(!input.wheels, WHEEL_SKIP); + // 40 steps of 6.5 px: 260 px, past half of the 360 px page. + for (let i = 0; i < 40; i++) { + await input.wheel({...at, dx: 6.5}); + } + await page.waitForTimeout(300); + await waitForRest(track); + await expect(field).toHaveValue('2026-04'); + }); +}); diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index f6316b7387003..d2f84e91c57e8 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -283,10 +283,6 @@ touch sample) followed on Oct 25 2026. A drag is decided on the first sample where either axis reaches the pan's hysteresis: the grid's when the horizontal travel has. -| ID | What | App and gesture | Extract | Method | Feeds | -| ------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------- | -| M-DP02 | Trackpad paging | `.date` `.inline` on an iPad with a pointer: two-finger horizontal swipes of 10 to 60 pt, slow and fast, and swipes 50 to 300 ms apart | whether the grid follows the swipe and pages on its end, the travel and speed that turn a month, and the gap after which a swipe counts as a new gesture | C | the wheel step and quiet time in `DateInput/MonthPager.tsx` | - ## Context menu (UIContextMenuInteraction) Values the context menu reads from its own table, which lands with the From 2a0703f00f5a31a006038c83573f02c7b1b17339 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:55 -0800 Subject: [PATCH 05/40] refactor(DateInput): the scroller and the pager share one month pane MonthPane.tsx now holds the month grid, its day styles and the grid keys that MonthScroller and MonthPager each kept a copy of, in step by a SYNC comment. Each surface passes its own pane style (the scroller's snap, the pager's layer) and whether the pane takes a tab stop. Nothing renders differently. --- .../src/DateInput/DateInputTouch.test.tsx | 29 +- packages/core/src/DateInput/MonthPager.tsx | 387 +----------------- packages/core/src/DateInput/MonthPane.tsx | 379 +++++++++++++++++ packages/core/src/DateInput/MonthScroller.tsx | 372 +---------------- 4 files changed, 425 insertions(+), 742 deletions(-) create mode 100644 packages/core/src/DateInput/MonthPane.tsx diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 84eb37f789701..6a7e52f46ca11 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -3204,9 +3204,9 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(source).toContain("touchAction: 'pan-x'"); // The pane and the scrollport must come from the same expression, or a // pane stops being exactly one screen and every snap offset drifts. - expect( - source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g), - ).toHaveLength(2); + const paneBlockSize = /blockSize: dateInputTouchGeometry\.paneBlockSize/g; + expect(source.match(paneBlockSize)).toHaveLength(1); + expect(read('MonthPane.tsx').match(paneBlockSize)).toHaveLength(1); }); it('under a finger: the month/year toggle leaves the calendar\u2019s days alone', () => { @@ -3228,21 +3228,20 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(source).toContain("willChange: 'transform'"); // The pane and the viewport must come from the same expression, or a // pane stops being exactly one screen. - expect( - source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g), - ).toHaveLength(2); + const paneBlockSize = /blockSize: dateInputTouchGeometry\.paneBlockSize/g; + expect(source.match(paneBlockSize)).toHaveLength(1); + expect(read('MonthPane.tsx').match(paneBlockSize)).toHaveLength(1); }); it('positions panes with logical properties, so RTL mirrors', () => { - const source = read('MonthScroller.tsx'); - // Scoped to the style objects: `scrollTo({left})` is the DOM API and is - // supposed to say left — it is the CSS that must stay logical, because - // physical `left` would lay the months out identically in both directions - // while the scroll math mirrored, and the two would disagree under RTL. + const source = read('MonthPane.tsx'); + // Scoped to the style objects. The CSS must stay logical. Physical `left` + // would lay the months out the same in both directions while the scroll + // math mirrored. Under RTL the two would disagree. const styles = source .slice( source.indexOf('const styles = stylex.create('), - source.indexOf('export interface MonthScrollerHandle'), + source.indexOf('export interface MonthPaneProps'), ) // Comments explain the rule ("insetInlineStart, not left") and would // otherwise trip it. @@ -3280,7 +3279,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(theme.length).toBeGreaterThan(0); // And the touch pane carries all of it, in its one object. - const touch = declarations(read('MonthScroller.tsx'), 'dayOutside'); + const touch = declarations(read('MonthPane.tsx'), 'dayOutside'); expect(touch).toEqual(expect.arrayContaining([...structural, ...theme])); }); @@ -3300,7 +3299,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { it('fades disabled days as the desktop does, rather than recolouring them', () => { const desktop = read('../Calendar/styles.ts'); const theme = declarationsIn(desktop, 'dayCellTheme', 'dayDisabled'); - const touch = declarations(read('MonthScroller.tsx'), 'dayDisabled'); + const touch = declarations(read('MonthPane.tsx'), 'dayDisabled'); // Whatever opacity the desktop fades to, this pane fades to the same one. const fade = theme.find(d => d.startsWith('opacity:')); @@ -3323,7 +3322,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { * than the selectable ones beside it. */ it('lets disabled win over outside on a spilled day past min/max', () => { - const applied = read('MonthScroller.tsx'); + const applied = read('MonthPane.tsx'); const outside = applied.indexOf('day.isOutside && styles.dayOutside'); const disabled = applied.indexOf('isDisabled && styles.dayDisabled'); expect(outside).toBeGreaterThan(-1); diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 69c0325d5498c..104cceda2a4b8 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -23,49 +23,21 @@ import { useEffect, useImperativeHandle, useLayoutEffect, - useMemo, useRef, useState, } from 'react'; import * as stylex from '@stylexjs/stylex'; -import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; import {usePager} from '../hooks/usePager'; import {useDirection} from '../i18n/useDirection'; -import {useLocale} from '../i18n/useLocale'; import {UIKIT_GESTURES} from '../motion/uikit/gestures'; -import { - colorVars, - radiusVars, - fontWeightVars, - typeScaleVars, - durationVars, - borderVars, -} from '../theme/tokens.stylex'; -import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import {observeResize} from '../utils/sharedResizeObserver'; -import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; -import { - plainDateAddDays, - plainDateDayOfWeek, - plainDateFormatDayNumber, - plainDateIsEqual, - plainDateToISO, - DATE_FORMAT_WITH_WEEKDAY, -} from '../utils/plainDate'; -import { - calendarShiftMonths, - calendarShiftYears, - calendarYearMonthOf, - formatCalendarDate, - formatCalendarMonthLabel, - isInCalendarMonth, -} from '../utils/calendarSystem'; -import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; +import type {PlainDate} from '../utils/dateTypes'; +import {plainDateIsEqual, plainDateToISO} from '../utils/plainDate'; +import {dateInputTouchGeometry} from './tokens.stylex'; import type {MonthScrollerProps} from './MonthScroller'; +import {MonthPane, monthGridKeyTarget} from './MonthPane'; import {useOwnScrollGesture} from './useOwnScrollGesture'; -import {type MonthModel, clampIndex, paneWindow, rowsIn} from './monthGeometry'; - -const DAY_SIZE = dateInputTouchSizes.daySize; +import {clampIndex, paneWindow, rowsIn} from './monthGeometry'; /** * Panes mounted on each side of the target month: UIKit's calendar keeps @@ -96,136 +68,12 @@ const styles = stylex.create({ // Pinch-zoom stays the browser's. touchAction: 'pinch-zoom', }, - pane: { - position: 'absolute', - insetBlock: 0, - blockSize: dateInputTouchGeometry.paneBlockSize, - display: 'grid', - gridTemplateColumns: 'repeat(7, 1fr)', - gridTemplateRows: dateInputTouchGeometry.paneRows, - // Its own layer, painted whole before it moves: through a busy main - // thread, Mobile Safari moves the track but cannot paint a new page. + /** Each pane on its own layer. */ + paneLayer: { + // Painted whole before it moves: through a busy main thread, Mobile + // Safari moves the track but cannot paint a new page. willChange: 'transform', }, - row: { - display: 'contents', - }, - cell: { - position: 'relative', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - day: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: '100%', - blockSize: '100%', - minInlineSize: DAY_SIZE, - minBlockSize: DAY_SIZE, - padding: 0, - borderWidth: 0, - borderStyle: 'none', - backgroundColor: 'transparent', - color: colorVars['--color-text-primary'], - fontSize: typeScaleVars['--text-body-size'], - fontWeight: fontWeightVars['--font-weight-normal'], - cursor: { - default: 'pointer', - ':is(:disabled,[aria-disabled="true"])': 'default', - }, - // A tap on a 44px target should not also select the number inside it. - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - }, - /** - * The circular hit/selection puck inside the tap target. Sizing the puck - * rather than the button keeps the touch target at the full cell while the - * visual stays a tidy circle. - */ - puck: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: `calc(${DAY_SIZE} - 8px)`, - blockSize: `calc(${DAY_SIZE} - 8px)`, - borderRadius: radiusVars['--radius-full'], - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderColor: 'transparent', - backgroundColor: 'transparent', - transitionProperty: 'background-color, color, border-color', - transitionDuration: durationVars['--duration-fast'], - }, - puckHoverable: { - // Two guards, for two different failure modes. - // - // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the - // last tapped day until something else is tapped. - // - // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a - // disabled control's events, not its hover styling, so an unguarded - // :hover tints a day you cannot pick. The disabled state sits on the - // button, and this puck is the span inside it. So the call site's gate - // does the work, and this guard holds if the styles move to the button. - backgroundColor: { - default: 'transparent', - '@media (hover: hover)': { - default: 'transparent', - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-overlay-hover'], - }, - }, - }, - puckToday: { - borderColor: colorVars['--color-border-emphasized'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - puckSelected: { - backgroundColor: { - default: colorVars['--color-accent'], - // Holds the accent through hover, overriding puckHoverable's tint. - // Same guarded selector, so the two have equal specificity and - // application order decides — which is what puts this one on top. - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-accent'], - }, - borderColor: colorVars['--color-accent'], - color: colorVars['--color-on-accent'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - /** - * A day belonging to the month either side of this pane. It is muted and - * unselectable, as in Calendar: it shows where the month ends. A tap out of - * the pane would move the calendar under the thumb. - * - * It takes no tab stop: `tabbableISO` names only a date in its own pane's - * month, and only the shown pane (`isCurrent`) has a tab stop. So a date - * never has two tab stops. - */ - dayOutside: { - // Calendar's spill-day treatment: the secondary text colour and half - // opacity, so these days read as background, not as dimmer choices. - color: colorVars['--color-text-secondary'], - opacity: 0.5, - }, - dayDisabled: { - // Calendar's disabled treatment: it fades the day's own colour to 0.3. - // So a disabled in-month day and a disabled spill day stay a step apart, - // and both read lighter than an enabled spill day. - opacity: 0.3, - cursor: 'default', - }, -}); - -const dynamic = stylex.create({ - // insetInlineStart, not left: under RTL the panes lay out from the right, - // and the pager mirrors its track with them. - pane: (insetInlineStart: number, inlineSize: number) => ({ - insetInlineStart: `${insetInlineStart}px`, - inlineSize: `${inlineSize}px`, - }), }); /** @@ -474,63 +322,14 @@ function MonthPagerComponent({ const isRTL = useDirection() === 'rtl'; const handleKeyDown = useCallback( (event: React.KeyboardEvent, date: PlainDate) => { - // Column within the displayed week, which is what Home/End move to — - // not the day of the month. - const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; - // The horizontal arrows follow the reading direction (APG grid): under - // RTL the next day sits to the left. - const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; - const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; - const byDays = (days: number) => { + const next = monthGridKeyTarget(event, date, { + calendar: monthModel.calendar, + isRTL, + weekStartsOn, + }); + if (next != null) { event.preventDefault(); - moveFocusTo(date, plainDateAddDays(date, days)); - }; - const byMonths = (months: number) => { - event.preventDefault(); - moveFocusTo( - date, - calendarShiftMonths(monthModel.calendar, date, months), - ); - }; - const byYears = (years: number) => { - event.preventDefault(); - moveFocusTo(date, calendarShiftYears(monthModel.calendar, date, years)); - }; - switch (event.key) { - case backward: - byDays(-1); - break; - case forward: - byDays(1); - break; - case 'ArrowUp': - byDays(-7); - break; - case 'ArrowDown': - byDays(7); - break; - case 'Home': - byDays(-column); - break; - case 'End': - byDays(6 - column); - break; - case 'PageUp': - if (event.shiftKey) { - byYears(-1); - } else { - byMonths(-1); - } - break; - case 'PageDown': - if (event.shiftKey) { - byYears(1); - } else { - byMonths(1); - } - break; - default: - break; + moveFocusTo(date, next); } }, [isRTL, monthModel, moveFocusTo, weekStartsOn], @@ -590,7 +389,8 @@ function MonthPagerComponent({ onSelect={onSelect} onDayKeyDown={handleKeyDown} onDayFocus={handleDayFocus} - isCurrent={row === centerRow} + hasTabStop={row === centerRow} + paneStyle={styles.paneLayer} /> ))}
@@ -606,154 +406,3 @@ function MonthPagerComponent({ export const MonthPager = memo(MonthPagerComponent); MonthPager.displayName = 'MonthPager'; - -interface MonthPaneProps { - monthModel: MonthModel; - monthIndex: number; - insetInlineStart: number; - inlineSize: number; - selectedDate: PlainDate | null; - focusedDate: PlainDate | null; - today: PlainDate; - isDateDisabled: (date: PlainDate) => boolean; - weekStartsOn: DayOfWeek; - onSelect: (value: ISODateString) => void; - onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; - onDayFocus: (date: PlainDate) => void; - /** - * Whether this is the month the pager shows. Only its day takes a tab stop: - * a neighbour is mounted off-screen, and Tab into it would scroll the - * clipped viewport under the pager's track. - */ - isCurrent: boolean; -} - -/** - * One month, as a six-row grid. - * - * It has no `role="columnheader"` row: the weekday names are one sticky row - * outside the pager, so they never scroll away. Each day carries its weekday - * in its accessible name instead. - */ -const MonthPane = memo(function MonthPane({ - monthModel, - monthIndex, - insetInlineStart, - inlineSize, - selectedDate, - focusedDate, - today, - isDateDisabled, - weekStartsOn, - onSelect, - onDayKeyDown, - onDayFocus, - isCurrent, -}: MonthPaneProps) { - const locale = useLocale(); - const {calendar} = monthModel; - const month = useMemo( - () => monthModel.monthAt(monthIndex), - [monthModel, monthIndex], - ); - const {weeks} = useCalendarDays({ - ...calendarYearMonthOf(calendar, month), - calendar, - weekStartsOn, - // Always six rows: a variable grid would make pane heights differ, and - // with them every snap offset below this month. - hasVariableRowCount: false, - }); - - const monthLabel = formatCalendarMonthLabel(calendar, month, locale); - - // One day per pane is tab-reachable, so Tab moves through the picker, not - // through 42 buttons. It is the focused day, else the selection, else the - // first of the month. - const tabbableISO = (() => { - if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { - return plainDateToISO(focusedDate); - } - if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { - return plainDateToISO(selectedDate); - } - return plainDateToISO(month.start); - })(); - - return ( -
- {weeks.map(week => ( -
- {week.map(day => { - // A spilled day is context, not a choice, as in Calendar's - // `effectivelyDisabled`. So a date borrowed from a neighbouring - // month carries no ring or puck in the pane that only shows it. - const isDisabled = day.isOutside || isDateDisabled(day.date); - const isSelected = - !day.isOutside && - selectedDate != null && - plainDateIsEqual(day.date, selectedDate); - const isToday = !day.isOutside && plainDateIsEqual(day.date, today); - - return ( -
- -
- ); - })} -
- ))} -
- ); -}); - -MonthPane.displayName = 'MonthPane'; diff --git a/packages/core/src/DateInput/MonthPane.tsx b/packages/core/src/DateInput/MonthPane.tsx new file mode 100644 index 0000000000000..2a6f26f16c4ef --- /dev/null +++ b/packages/core/src/DateInput/MonthPane.tsx @@ -0,0 +1,379 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file MonthPane.tsx + * @input A month, the selection, the focused day and the day handlers + * @output MonthPane: one month as a six-row grid of day buttons; and + * monthGridKeyTarget: the day a grid key moves the focus to + * @position Internal leaf shared by MonthScroller.tsx and MonthPager.tsx + * + * SYNC: When modified, update DateInputTouch.test.tsx. + */ + +import {memo, useMemo} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; +import {useLocale} from '../i18n/useLocale'; +import { + colorVars, + radiusVars, + fontWeightVars, + typeScaleVars, + durationVars, + borderVars, +} from '../theme/tokens.stylex'; +import {focusOutlineStyles} from '../utils/focusOutline.stylex'; +import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; +import { + plainDateAddDays, + plainDateDayOfWeek, + plainDateFormatDayNumber, + plainDateIsEqual, + plainDateToISO, + DATE_FORMAT_WITH_WEEKDAY, +} from '../utils/plainDate'; +import { + type CalendarSystem, + calendarShiftMonths, + calendarShiftYears, + calendarYearMonthOf, + formatCalendarDate, + formatCalendarMonthLabel, + isInCalendarMonth, +} from '../utils/calendarSystem'; +import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; +import type {MonthModel} from './monthGeometry'; + +const DAY_SIZE = dateInputTouchSizes.daySize; + +const styles = stylex.create({ + pane: { + position: 'absolute', + insetBlock: 0, + blockSize: dateInputTouchGeometry.paneBlockSize, + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gridTemplateRows: dateInputTouchGeometry.paneRows, + }, + row: { + display: 'contents', + }, + cell: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + day: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: '100%', + blockSize: '100%', + minInlineSize: DAY_SIZE, + minBlockSize: DAY_SIZE, + padding: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + fontSize: typeScaleVars['--text-body-size'], + fontWeight: fontWeightVars['--font-weight-normal'], + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + // A tap on a 44px target should not also select the number inside it. + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }, + /** + * The circular hit/selection puck inside the tap target. Sizing the puck + * rather than the button keeps the touch target at the full cell while the + * visual stays a tidy circle. + */ + puck: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: `calc(${DAY_SIZE} - 8px)`, + blockSize: `calc(${DAY_SIZE} - 8px)`, + borderRadius: radiusVars['--radius-full'], + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: 'transparent', + backgroundColor: 'transparent', + transitionProperty: 'background-color, color, border-color', + transitionDuration: durationVars['--duration-fast'], + }, + puckHoverable: { + // Two guards, for two different failure modes. + // + // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the + // last tapped day until something else is tapped. + // + // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a + // disabled control's events, not its hover styling, so an unguarded + // :hover tints a day you cannot pick. The disabled state sits on the + // button, and this puck is the span inside it. So the call site's gate + // does the work, and this guard holds if the styles move to the button. + backgroundColor: { + default: 'transparent', + '@media (hover: hover)': { + default: 'transparent', + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-overlay-hover'], + }, + }, + }, + puckToday: { + borderColor: colorVars['--color-border-emphasized'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + puckSelected: { + backgroundColor: { + default: colorVars['--color-accent'], + // Holds the accent through hover, overriding puckHoverable's tint. + // Same guarded selector, so the two have equal specificity and + // application order decides — which is what puts this one on top. + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-accent'], + }, + borderColor: colorVars['--color-accent'], + color: colorVars['--color-on-accent'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + /** + * A day belonging to the month either side of this pane. It is muted and + * unselectable, as in Calendar: it shows where the month ends. A tap out of + * the pane would move the calendar under the thumb. + * + * It takes no tab stop: `tabbableISO` names only a date in its own pane's + * month. So a date never has two tab stops. + */ + dayOutside: { + // Calendar's spill-day treatment: the secondary text colour and half + // opacity, so these days read as background, not as dimmer choices. + color: colorVars['--color-text-secondary'], + opacity: 0.5, + }, + dayDisabled: { + // Calendar's disabled treatment: it fades the day's own colour to 0.3. + // So a disabled in-month day and a disabled spill day stay a step apart, + // and both read lighter than an enabled spill day. + opacity: 0.3, + cursor: 'default', + }, +}); + +const dynamic = stylex.create({ + // insetInlineStart, not left: under RTL the panes lay out from the right, + // and the scroller and the pager mirror with them. + pane: (insetInlineStart: number, inlineSize: number) => ({ + insetInlineStart: `${insetInlineStart}px`, + inlineSize: `${inlineSize}px`, + }), +}); + +export interface MonthPaneProps { + /** The calendar's months, by index. */ + monthModel: MonthModel; + monthIndex: number; + insetInlineStart: number; + inlineSize: number; + selectedDate: PlainDate | null; + focusedDate: PlainDate | null; + today: PlainDate; + isDateDisabled: (date: PlainDate) => boolean; + weekStartsOn: DayOfWeek; + onSelect: (value: ISODateString) => void; + onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; + onDayFocus: (date: PlainDate) => void; + /** + * Whether one of this pane's days takes a tab stop. The pager gives it only + * to the month it shows. A neighbour is mounted off-screen, and Tab into it + * would scroll the clipped viewport under the track. + */ + hasTabStop: boolean; + /** The surface's own pane style: the scroller's snap or the pager's layer. */ + paneStyle?: stylex.StyleXStyles; +} + +/** + * One month, as a six-row grid. + * + * It has no `role="columnheader"` row: the weekday names are one sticky row + * outside the scroller or the pager, so they never scroll away. Each day + * carries its weekday in its accessible name instead. + */ +export const MonthPane = memo(function MonthPane({ + monthModel, + monthIndex, + insetInlineStart, + inlineSize, + selectedDate, + focusedDate, + today, + isDateDisabled, + weekStartsOn, + onSelect, + onDayKeyDown, + onDayFocus, + hasTabStop, + paneStyle, +}: MonthPaneProps) { + const locale = useLocale(); + const {calendar} = monthModel; + const month = useMemo( + () => monthModel.monthAt(monthIndex), + [monthModel, monthIndex], + ); + const {weeks} = useCalendarDays({ + ...calendarYearMonthOf(calendar, month), + calendar, + weekStartsOn, + // Always six rows: a variable grid would make pane heights differ, and + // with them every snap offset below this month. + hasVariableRowCount: false, + }); + + const monthLabel = formatCalendarMonthLabel(calendar, month, locale); + + // One day per pane is tab-reachable, so Tab moves through the picker, not + // through 42 buttons. It is the focused day, else the selection, else the + // first of the month. + const tabbableISO = (() => { + if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { + return plainDateToISO(focusedDate); + } + if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { + return plainDateToISO(selectedDate); + } + return plainDateToISO(month.start); + })(); + + return ( +
+ {weeks.map(week => ( +
+ {week.map(day => { + // A spilled day is context, not a choice, as in Calendar's + // `effectivelyDisabled`. So a date borrowed from a neighbouring + // month carries no ring or puck in the pane that only shows it. + const isDisabled = day.isOutside || isDateDisabled(day.date); + const isSelected = + !day.isOutside && + selectedDate != null && + plainDateIsEqual(day.date, selectedDate); + const isToday = !day.isOutside && plainDateIsEqual(day.date, today); + + return ( +
+ +
+ ); + })} +
+ ))} +
+ ); +}); + +MonthPane.displayName = 'MonthPane'; + +/** + * The day a grid key moves the focus to from `date`, or null for any other + * key. The vocabulary is the APG grid's: the arrows follow the reading + * direction, and Home and End reach the ends of the shown week. PageUp and + * PageDown move a month of the calendar, and with Shift a year, as Calendar + * does. + */ +export function monthGridKeyTarget( + event: React.KeyboardEvent, + date: PlainDate, + { + calendar, + isRTL, + weekStartsOn, + }: {calendar: CalendarSystem; isRTL: boolean; weekStartsOn: DayOfWeek}, +): PlainDate | null { + const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; + const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; + const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; + switch (event.key) { + case backward: + return plainDateAddDays(date, -1); + case forward: + return plainDateAddDays(date, 1); + case 'ArrowUp': + return plainDateAddDays(date, -7); + case 'ArrowDown': + return plainDateAddDays(date, 7); + case 'Home': + return plainDateAddDays(date, -column); + case 'End': + return plainDateAddDays(date, 6 - column); + case 'PageUp': + return event.shiftKey + ? calendarShiftYears(calendar, date, -1) + : calendarShiftMonths(calendar, date, -1); + case 'PageDown': + return event.shiftKey + ? calendarShiftYears(calendar, date, 1) + : calendarShiftMonths(calendar, date, 1); + default: + return null; + } +} diff --git a/packages/core/src/DateInput/MonthScroller.tsx b/packages/core/src/DateInput/MonthScroller.tsx index ffa56188b05e8..3b9731478bc65 100644 --- a/packages/core/src/DateInput/MonthScroller.tsx +++ b/packages/core/src/DateInput/MonthScroller.tsx @@ -37,47 +37,25 @@ import { useImperativeHandle, useLayoutEffect, useRef, - useMemo, useState, } from 'react'; import {flushSync} from 'react-dom'; import * as stylex from '@stylexjs/stylex'; -import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; import {useDirection} from '../i18n/useDirection'; -import {useLocale} from '../i18n/useLocale'; -import { - colorVars, - radiusVars, - fontWeightVars, - typeScaleVars, - durationVars, - borderVars, -} from '../theme/tokens.stylex'; -import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; import { plainDateAddDays, - plainDateDayOfWeek, plainDateDiffDays, - plainDateFormatDayNumber, plainDateIsEqual, plainDateToISO, - DATE_FORMAT_WITH_WEEKDAY, } from '../utils/plainDate'; import { playReducedMotionDip, reducedMotionFade, type ReducedMotionDip, } from '../motion/reducedMotion'; -import { - calendarShiftMonths, - calendarShiftYears, - calendarYearMonthOf, - formatCalendarDate, - formatCalendarMonthLabel, - isInCalendarMonth, -} from '../utils/calendarSystem'; -import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; +import {dateInputTouchGeometry} from './tokens.stylex'; +import {MonthPane, monthGridKeyTarget} from './MonthPane'; import {useOwnScrollGesture} from './useOwnScrollGesture'; import {useScrollSettle} from './useScrollSettle'; import { @@ -88,8 +66,6 @@ import { scrollOffsetForRow, } from './monthGeometry'; -const DAY_SIZE = dateInputTouchSizes.daySize; - /** * Panes mounted on each side of the visible one. Sized against a fast fling: * the window widens once per animation frame, so it only fails if a single @@ -130,144 +106,13 @@ const styles = stylex.create({ position: 'relative', blockSize: '100%', }, - pane: { - position: 'absolute', - insetBlock: 0, - blockSize: dateInputTouchGeometry.paneBlockSize, + /** Each pane snaps at its start. */ + paneSnap: { scrollSnapAlign: 'start', // No `scroll-snap-stop: always`. It would cap every fling at one month, // which is tidy but stops the surface being continuous — "three months // ahead" should be one throw, not three flicks. Momentum carrying past // several panes is why OVERSCAN is what it is. - display: 'grid', - gridTemplateColumns: 'repeat(7, 1fr)', - gridTemplateRows: dateInputTouchGeometry.paneRows, - }, - row: { - display: 'contents', - }, - cell: { - position: 'relative', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - day: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: '100%', - blockSize: '100%', - minInlineSize: DAY_SIZE, - minBlockSize: DAY_SIZE, - padding: 0, - borderWidth: 0, - borderStyle: 'none', - backgroundColor: 'transparent', - color: colorVars['--color-text-primary'], - fontSize: typeScaleVars['--text-body-size'], - fontWeight: fontWeightVars['--font-weight-normal'], - cursor: { - default: 'pointer', - ':is(:disabled,[aria-disabled="true"])': 'default', - }, - // A tap on a 44px target should not also select the number inside it. - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - }, - /** - * The circular hit/selection puck inside the tap target. Sizing the puck - * rather than the button keeps the touch target at the full cell while the - * visual stays a tidy circle. - */ - puck: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: `calc(${DAY_SIZE} - 8px)`, - blockSize: `calc(${DAY_SIZE} - 8px)`, - borderRadius: radiusVars['--radius-full'], - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderColor: 'transparent', - backgroundColor: 'transparent', - transitionProperty: 'background-color, color, border-color', - transitionDuration: durationVars['--duration-fast'], - }, - puckHoverable: { - // Two guards, for two different failure modes. - // - // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the - // last tapped day until something else is tapped. - // - // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a - // disabled control's EVENTS, not its hover styling, so an unguarded - // :hover tints a day you cannot pick. The day's disabled state lives on - // the button (as `aria-disabled`, which keeps it focusable) and this puck - // is the span inside it, so today the working mechanism is the JS gate at - // the call site — this guard is what keeps the rule true if the styles - // ever move onto the button itself. - backgroundColor: { - default: 'transparent', - '@media (hover: hover)': { - default: 'transparent', - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-overlay-hover'], - }, - }, - }, - puckToday: { - borderColor: colorVars['--color-border-emphasized'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - puckSelected: { - backgroundColor: { - default: colorVars['--color-accent'], - // Holds the accent through hover, overriding puckHoverable's tint. - // Same guarded selector, so the two have equal specificity and - // application order decides — which is what puts this one on top. - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-accent'], - }, - borderColor: colorVars['--color-accent'], - color: colorVars['--color-on-accent'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - /** - * A day belonging to the month either side of this pane. Muted the way - * Calendar mutes its own, and unselectable for the same reason it is - * there: it exists to show where the month ends, not to offer a date. A - * pane is one month, and tapping out of it would move the calendar under - * your thumb; the swipe or the arrow says "next month" without ambiguity. - * - * It needs no special handling for the roving tab order: `tabbableISO` is - * resolved per pane and only ever names a date in THAT pane's own month, - * so an outside cell can never match it. Which is the property that keeps - * a date from having two tab stops — the pane that owns it, and the - * neighbour that merely shows it. - */ - dayOutside: { - // Exactly what the desktop calendar does to its own spill days: the - // secondary text colour AND half opacity, which is what makes them read - // as background rather than as dimmer choices. (Calendar splits these - // across two style objects — `dayOutside` in its theme layer carries the - // colour, the one in its structural layer the opacity — so applying only - // the colour, as this did at first, was half the treatment.) - color: colorVars['--color-text-secondary'], - opacity: 0.5, - }, - dayDisabled: { - // The desktop's disabled treatment, and deliberately not a disabled - // COLOUR: it fades whatever colour the day already had (0.3), so an - // in-month day and a spilled one stay a step apart while disabled, and - // both land LIGHTER than an enabled spill day rather than darker. - // - // A flat `--color-text-disabled` is what this had, and it put an enabled - // adjacent day at ~168 against a disabled in-month day at ~163 on white: - // five levels apart, which is no difference at all. That is the whole - // reason the two were hard to tell apart. - opacity: 0.3, - cursor: 'default', }, }); @@ -275,12 +120,6 @@ const dynamic = stylex.create({ spacer: (inlineSize: number) => ({ inlineSize: `${inlineSize}px`, }), - // insetInlineStart, not left: under RTL the panes have to lay out from the - // right, and the scroll math mirrors with them (see scrollOffsetForRow). - pane: (insetInlineStart: number, inlineSize: number) => ({ - insetInlineStart: `${insetInlineStart}px`, - inlineSize: `${inlineSize}px`, - }), }); /** The handle of MonthScroller and of MonthPager, its touch counterpart. */ @@ -792,52 +631,14 @@ export function MonthScroller({ const handleKeyDown = useCallback( (event: React.KeyboardEvent, date: PlainDate) => { - // Column within the displayed week, which is what Home/End move to — - // not the day of the month. - const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; - // The horizontal arrows follow the reading direction (APG grid): under - // RTL the next day sits to the left. - const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; - const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; - switch (event.key) { - case backward: - event.preventDefault(); - moveFocus(date, -1); - break; - case forward: - event.preventDefault(); - moveFocus(date, 1); - break; - case 'ArrowUp': - event.preventDefault(); - moveFocus(date, -7); - break; - case 'ArrowDown': - event.preventDefault(); - moveFocus(date, 7); - break; - case 'Home': - event.preventDefault(); - moveFocus(date, -column); - break; - case 'End': - event.preventDefault(); - moveFocus(date, 6 - column); - break; - // A month (a year with Shift), as Calendar does: the same day in the - // month before or after, or that month's last day. - case 'PageUp': - case 'PageDown': { - event.preventDefault(); - const step = event.key === 'PageDown' ? 1 : -1; - const next = event.shiftKey - ? calendarShiftYears(monthModel.calendar, date, step) - : calendarShiftMonths(monthModel.calendar, date, step); - moveFocus(date, plainDateDiffDays(date, next)); - break; - } - default: - break; + const next = monthGridKeyTarget(event, date, { + calendar: monthModel.calendar, + isRTL, + weekStartsOn, + }); + if (next != null) { + event.preventDefault(); + moveFocus(date, plainDateDiffDays(date, next)); } }, [isRTL, monthModel, moveFocus, weekStartsOn], @@ -899,6 +700,8 @@ export function MonthScroller({ onSelect={onSelect} onDayKeyDown={handleKeyDown} onDayFocus={handleDayFocus} + hasTabStop + paneStyle={styles.paneSnap} /> ))} @@ -907,150 +710,3 @@ export function MonthScroller({ } MonthScroller.displayName = 'MonthScroller'; - -interface MonthPaneProps { - monthModel: MonthModel; - monthIndex: number; - insetInlineStart: number; - inlineSize: number; - selectedDate: PlainDate | null; - focusedDate: PlainDate | null; - today: PlainDate; - isDateDisabled: (date: PlainDate) => boolean; - weekStartsOn: DayOfWeek; - onSelect: (value: ISODateString) => void; - onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; - onDayFocus: (date: PlainDate) => void; -} - -/** - * One month, as a six-row grid. - * - * There is no `role="columnheader"` row in here: the weekday names are a - * single sticky row outside the scroller (they would otherwise scroll away), - * so each day instead carries its weekday in its accessible name. - */ -function MonthPane({ - monthModel, - monthIndex, - insetInlineStart, - inlineSize, - selectedDate, - focusedDate, - today, - isDateDisabled, - weekStartsOn, - onSelect, - onDayKeyDown, - onDayFocus, -}: MonthPaneProps) { - const locale = useLocale(); - const {calendar} = monthModel; - const month = useMemo( - () => monthModel.monthAt(monthIndex), - [monthModel, monthIndex], - ); - const {weeks} = useCalendarDays({ - ...calendarYearMonthOf(calendar, month), - calendar, - weekStartsOn, - // Always six rows: a variable grid would make pane heights differ, and - // with them every snap offset below this month. - hasVariableRowCount: false, - }); - - const monthLabel = formatCalendarMonthLabel(calendar, month, locale); - - // Exactly one day per pane is tab-reachable, so Tab moves through the - // picker rather than through 42 buttons: the focused day if the keyboard - // owns one, else the selection, else the first of the month. - const tabbableISO = (() => { - if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { - return plainDateToISO(focusedDate); - } - if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { - return plainDateToISO(selectedDate); - } - return plainDateToISO(month.start); - })(); - - return ( -
- {weeks.map(week => ( -
- {week.map(day => { - // A spilled day is context, not a choice. Calendar computes the - // same thing as `effectivelyDisabled: isDisabled || isOutside`, - // and guards today/selected on `!isOutside` beside it, so a date - // borrowed from a neighbouring month never carries a ring or a - // puck in the pane that is only showing it. - const isDisabled = day.isOutside || isDateDisabled(day.date); - const isSelected = - !day.isOutside && - selectedDate != null && - plainDateIsEqual(day.date, selectedDate); - const isToday = !day.isOutside && plainDateIsEqual(day.date, today); - - return ( -
- -
- ); - })} -
- ))} -
- ); -} - -MonthPane.displayName = 'MonthPane'; From 8669954bfcf69e12ae7ea55026aa1c2f257d127c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:55 -0800 Subject: [PATCH 06/40] refactor(DateInput): the scroller and the pager share one keyboard-focus hook useMonthGridFocus holds the focused day, the grid keys and the focus restore that MonthScroller and MonthPager each kept a copy of. Nothing behaves differently. --- packages/core/src/DateInput/MonthPager.tsx | 89 ++--------- packages/core/src/DateInput/MonthScroller.tsx | 89 ++--------- .../core/src/DateInput/useMonthGridFocus.ts | 139 ++++++++++++++++++ 3 files changed, 164 insertions(+), 153 deletions(-) create mode 100644 packages/core/src/DateInput/useMonthGridFocus.ts diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 104cceda2a4b8..b18b6f508c05a 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -28,14 +28,12 @@ import { } from 'react'; import * as stylex from '@stylexjs/stylex'; import {usePager} from '../hooks/usePager'; -import {useDirection} from '../i18n/useDirection'; import {UIKIT_GESTURES} from '../motion/uikit/gestures'; import {observeResize} from '../utils/sharedResizeObserver'; -import type {PlainDate} from '../utils/dateTypes'; -import {plainDateIsEqual, plainDateToISO} from '../utils/plainDate'; import {dateInputTouchGeometry} from './tokens.stylex'; import type {MonthScrollerProps} from './MonthScroller'; -import {MonthPane, monthGridKeyTarget} from './MonthPane'; +import {MonthPane} from './MonthPane'; +import {useMonthGridFocus} from './useMonthGridFocus'; import {useOwnScrollGesture} from './useOwnScrollGesture'; import {clampIndex, paneWindow, rowsIn} from './monthGeometry'; @@ -114,12 +112,6 @@ function MonthPagerComponent({ // A far jump waits for its pane to mount before the track moves. const pendingRowRef = useRef(null); - // Keyboard focus moves by date, not by cell index, so it crosses month - // boundaries the way a calendar should. Null until the user takes the grid - // with the keyboard. - const [focusedDate, setFocusedDate] = useState(null); - const shouldRestoreFocusRef = useRef(false); - // Read through a ref, so a caller need not memoize the callback. const onVisibleMonthChangeRef = useRef(onVisibleMonthChange); onVisibleMonthChangeRef.current = onVisibleMonthChange; @@ -244,9 +236,9 @@ function MonthPagerComponent({ [pager, report, rowCount, followSettle], ); - // A steered move (an arrow, a key, the wheels, a trackpad step) names its - // month. The settle it interrupts stops reporting, so the title stays on - // the step's month (`datePicker.calendar.chevron.retarget`). + // An arrow, a key or the wheels name the month. The settle they interrupt + // stops reporting, so the title stays on that month + // (`datePicker.calendar.chevron.retarget`). const stopFollowingSettle = useCallback(() => { if (settleFrameRef.current != null) { cancelAnimationFrame(settleFrameRef.current); @@ -300,73 +292,20 @@ function MonthPagerComponent({ // the same rule (above). useOwnScrollGesture(viewportRef, 'pager'); - // Move the keyboard focus by whole days (or months) and let the pager - // follow. The vocabulary is the APG grid's, plus PageUp/PageDown for a - // month and Shift with them for a year, as Calendar does. - const moveFocusTo = useCallback( - (from: PlainDate, next: PlainDate) => { - const nextIndex = monthModel.indexOf(next); - if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { - return; - } - shouldRestoreFocusRef.current = true; - setFocusedDate(next); - if (nextIndex !== monthModel.indexOf(from)) { - scrollToMonth(nextIndex, 'smooth'); - onVisibleMonthChangeRef.current(nextIndex); - } - }, - [monthModel, minMonthIndex, maxMonthIndex, scrollToMonth], - ); - - const isRTL = useDirection() === 'rtl'; - const handleKeyDown = useCallback( - (event: React.KeyboardEvent, date: PlainDate) => { - const next = monthGridKeyTarget(event, date, { - calendar: monthModel.calendar, - isRTL, - weekStartsOn, - }); - if (next != null) { - event.preventDefault(); - moveFocusTo(date, next); - } + const {focusedDate, handleKeyDown, handleDayFocus} = useMonthGridFocus( + viewportRef, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, }, - [isRTL, monthModel, moveFocusTo, weekStartsOn], ); - const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); - // Syncs DOM focus with the focused day. The day may sit in a pane mounted - // only this frame: it takes focus once it exists. Only a key press moves - // it, so a drag never steals focus. - useEffect(() => { - if (!shouldRestoreFocusRef.current || focusedISO == null) { - return; - } - // A date can be mounted twice: as a real day in its pane and as a - // disabled spill copy in a neighbour. Only the real day may take focus, - // or Enter and Space would select nothing. - const target = viewportRef.current?.querySelector( - `[data-date="${focusedISO}"]:not([data-outside])`, - ); - if (target != null) { - shouldRestoreFocusRef.current = false; - // preventScroll: the viewport does not scroll, and a browser - // scroll-into-view would shift it under the pager's track. - target.focus({preventScroll: true}); - } - }, [focusedISO]); - const visibleRows = paneWindow(centerRow, rowCount, OVERSCAN); - // A focus event fires for the programmatic focus above too; only take the - // date when it is actually a different day. - const handleDayFocus = useCallback((date: PlainDate) => { - setFocusedDate(previous => - previous != null && plainDateIsEqual(previous, date) ? previous : date, - ); - }, []); - return (
(null); - // Keyboard focus moves by date, not by cell index, so it crosses month - // boundaries the way a calendar should. Null until the user takes the grid - // with the keyboard. - const [focusedDate, setFocusedDate] = useState(null); - const shouldRestoreFocusRef = useRef(false); - const onVisibleMonthChangeRef = useRef(onVisibleMonthChange); onVisibleMonthChangeRef.current = onVisibleMonthChange; @@ -607,76 +596,20 @@ export function MonthScroller({ }; }, [paneSize, rowCount, minMonthIndex, isRTL, recenter]); - // Move the keyboard focus by whole days and let the scroller follow. Paging - // by month is PageUp/PageDown (a year with Shift); everything else is the - // APG grid vocabulary. - const moveFocus = useCallback( - (from: PlainDate, deltaDays: number) => { - const next = plainDateAddDays(from, deltaDays); - const nextIndex = monthModel.indexOf(next); - if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { - return; - } - shouldRestoreFocusRef.current = true; - setFocusedDate(next); - if (nextIndex !== monthModel.indexOf(from)) { - scrollToMonth(nextIndex, 'smooth'); - // Report the month here, as MonthPager does. The scroll handler drops - // every report while a steered scroll runs, its arrival included. - onVisibleMonthChangeRef.current(nextIndex); - } - }, - [monthModel, minMonthIndex, maxMonthIndex, scrollToMonth], - ); - - const handleKeyDown = useCallback( - (event: React.KeyboardEvent, date: PlainDate) => { - const next = monthGridKeyTarget(event, date, { - calendar: monthModel.calendar, - isRTL, - weekStartsOn, - }); - if (next != null) { - event.preventDefault(); - moveFocus(date, plainDateDiffDays(date, next)); - } + const {focusedDate, handleKeyDown, handleDayFocus} = useMonthGridFocus( + scrollerRef, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, }, - [isRTL, monthModel, moveFocus, weekStartsOn], ); - // The focused day may have been in an unmounted pane a frame ago; focus it - // once it exists, and only in response to a key press so a scroll never - // steals focus. - const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); - useEffect(() => { - if (!shouldRestoreFocusRef.current || focusedISO == null) { - return; - } - // A date can be mounted twice: as a real day in its own pane and as a - // dimmed, disabled spill copy at the edge of a neighbouring pane. Only - // the real day may take focus, or Enter and Space would select nothing. - const target = scrollerRef.current?.querySelector( - `[data-date="${focusedISO}"]:not([data-outside])`, - ); - if (target != null) { - shouldRestoreFocusRef.current = false; - // preventScroll: the scroller is already snapping to this month; a - // browser scroll-into-view here would fight the snap and land between - // two panes. - target.focus({preventScroll: true}); - } - }, [focusedISO]); - const visibleRows = paneWindow(centerRow, rowCount, OVERSCAN); - // A focus event fires for the programmatic focus above too; only take the - // date when it is actually a different day. - const handleDayFocus = useCallback((date: PlainDate) => { - setFocusedDate(previous => - previous != null && plainDateIsEqual(previous, date) ? previous : date, - ); - }, []); - return (
void; + /** The grid's month report, read through a ref. */ + onVisibleMonthChangeRef: RefObject<(monthIndex: number) => void>; +} + +export interface MonthGridFocus { + /** The day that has the keyboard focus, or null before the first key. */ + focusedDate: PlainDate | null; + /** The days' key handler. */ + handleKeyDown: (event: KeyboardEvent, date: PlainDate) => void; + /** The days' focus handler. */ + handleDayFocus: (date: PlainDate) => void; +} + +/** + * The month grid's keyboard focus. It moves by date, not by cell index, so it + * crosses months as a calendar does, and the grid follows it to the new month. + * Only a key press moves DOM focus, so a drag never takes it. + */ +export function useMonthGridFocus( + gridRef: RefObject, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, + }: MonthGridFocusOptions, +): MonthGridFocus { + const [focusedDate, setFocusedDate] = useState(null); + const shouldRestoreFocusRef = useRef(false); + + const moveFocusTo = useCallback( + (from: PlainDate, next: PlainDate) => { + const nextIndex = monthModel.indexOf(next); + if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { + return; + } + shouldRestoreFocusRef.current = true; + setFocusedDate(next); + if (nextIndex !== monthModel.indexOf(from)) { + scrollToMonth(nextIndex, 'smooth'); + // Neither grid reports a move it was told to make, so the month is + // reported here. + onVisibleMonthChangeRef.current(nextIndex); + } + }, + [ + monthModel, + minMonthIndex, + maxMonthIndex, + scrollToMonth, + onVisibleMonthChangeRef, + ], + ); + + const isRTL = useDirection() === 'rtl'; + const handleKeyDown = useCallback( + (event: KeyboardEvent, date: PlainDate) => { + const next = monthGridKeyTarget(event, date, { + calendar: monthModel.calendar, + isRTL, + weekStartsOn, + }); + if (next != null) { + event.preventDefault(); + moveFocusTo(date, next); + } + }, + [isRTL, monthModel, moveFocusTo, weekStartsOn], + ); + + const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); + // Syncs DOM focus with the focused day. The day may sit in a pane mounted + // only this frame: it takes focus once it exists. + useEffect(() => { + if (!shouldRestoreFocusRef.current || focusedISO == null) { + return; + } + // A date can be mounted twice: as a real day in its pane and as a + // disabled spill copy in a neighbour. Only the real day may take focus, + // or Enter and Space would select nothing. + const target = gridRef.current?.querySelector( + `[data-date="${focusedISO}"]:not([data-outside])`, + ); + if (target != null) { + shouldRestoreFocusRef.current = false; + // preventScroll: the grid already moves to this month. A browser + // scroll-into-view would fight the scroller's snap, or shift the + // pager's viewport under its track. + target.focus({preventScroll: true}); + } + }, [focusedISO, gridRef]); + + // A focus event fires for the programmatic focus above too; only take the + // date when it is actually a different day. + const handleDayFocus = useCallback((date: PlainDate) => { + setFocusedDate(previous => + previous != null && plainDateIsEqual(previous, date) ? previous : date, + ); + }, []); + + return {focusedDate, handleKeyDown, handleDayFocus}; +} From 24730063ef99e36a1a253b9541955bc0232c6ba7 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:55 -0800 Subject: [PATCH 07/40] test(DateInput): the sheet split's proof samples in the page The proof read the sheet and the grid with two evaluates per touch move. On a loaded runner that slowed the script, and the fast flick lost the speed that pages it. The page now samples both itself, every frame. --- .../MonthPager.interaction.browser.spec.ts | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 065b28d5af469..622306f55c32b 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -621,23 +621,54 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 x: center.x - length * Math.cos((degrees * Math.PI) / 180), y: center.y + length * Math.sin((degrees * Math.PI) / 180), }); - /** Plays `script`, sampling the sheet's drop and the grid's offset per move. */ + /** + * Plays `script` while the page records, every frame, the sheet's drop and + * the grid's offset. The page samples them itself: an evaluate per move + * would slow the script on a loaded runner and blunt the fast flick. + */ const play = async (script: Parameters[0]) => { - const seen = {sheetDrop: 0, monthsMin: Infinity, monthsMax: -Infinity}; - await driver.play(script, { - onEvent: async step => { - if (step.type === 'touchMove') { - seen.sheetDrop = Math.max( - seen.sheetDrop, - Math.abs((await sheetTop()) - topBefore), - ); - const months = await monthsIn(viewport); - seen.monthsMin = Math.min(seen.monthsMin, months); - seen.monthsMax = Math.max(seen.monthsMax, months); + await sheet.evaluate((sheetElement, top) => { + const months = document.querySelector('[data-scroller="months"]'); + const track = months?.querySelector('[data-month-track]'); + const seen = { + sheetDrop: 0, + monthsMin: Infinity, + monthsMax: -Infinity, + frame: 0, + }; + const sample = () => { + if (months == null || track == null) { + return; } - }, + const drop = Math.abs(sheetElement.getBoundingClientRect().y - top); + seen.sheetDrop = Math.max(seen.sheetDrop, drop); + const box = months.getBoundingClientRect(); + const offset = + (box.left - track.getBoundingClientRect().left) / box.width; + seen.monthsMin = Math.min(seen.monthsMin, offset); + seen.monthsMax = Math.max(seen.monthsMax, offset); + seen.frame = requestAnimationFrame(sample); + }; + sample(); + (window as unknown as {splitSeen: typeof seen}).splitSeen = seen; + }, topBefore); + await driver.play(script); + return page.evaluate(() => { + const {splitSeen} = window as unknown as { + splitSeen: { + sheetDrop: number; + monthsMin: number; + monthsMax: number; + frame: number; + }; + }; + cancelAnimationFrame(splitSeen.frame); + return { + sheetDrop: splitSeen.sheetDrop, + monthsMin: splitSeen.monthsMin, + monthsMax: splitSeen.monthsMax, + }; }); - return seen; }; // 35°, slow (4 px steps): the sideways travel reaches 10 px first. The From 7def99449ad24e2fc066a875d8ad7b113a46c910 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:55 -0800 Subject: [PATCH 08/40] fix(DateInput): the arrows' step names its FR45 gap, and the title reads the pager's page `spec:AST-094` FR45, now on main, has motion a tap starts play the theme's duration and easing tokens. An arrow's step plays the shared pager's animated scroll, UIKit's 300 ms sinusoid, which AST-094 FR4 already records as the pager's gap. DateInput's record now names that gap, and the changeset, the story and the browser spec stop promising 0.3 s. The arrow's proof checks that the step travels one month, one way, without fitting the curve. The title read the nearest month from MonthPager's own pane width. It now reads the pager's own page geometry through `nearestPage`. --- .../calendar-pages-like-uidatepicker.md | 2 +- apps/storybook/stories/DateInput.stories.tsx | 5 ++-- .../components/DateInput/DateInput.spec.md | 4 ++++ packages/core/src/DateInput/MonthPager.tsx | 24 +++++++------------ .../MonthPager.interaction.browser.spec.ts | 22 +++++++---------- 5 files changed, 25 insertions(+), 32 deletions(-) diff --git a/.changeset/calendar-pages-like-uidatepicker.md b/.changeset/calendar-pages-like-uidatepicker.md index 1c79d4a892d80..51541a42cb014 100644 --- a/.changeset/calendar-pages-like-uidatepicker.md +++ b/.changeset/calendar-pages-like-uidatepicker.md @@ -6,7 +6,7 @@ - A swipe moves at most one month, chosen at the lift by iOS's paging rule: a quick flick, or a drag past half a month. It settles on iOS's paging curve. - The month title follows the drag and the settle. It changes when the next month is more than half in view. -- The arrows scroll the month in 0.3 s. A second tap mid-scroll goes on to the month after. +- An arrow scrolls one month. A second tap mid-scroll goes on to the month after. - In the sheet, a drag is decided once it has moved 10 px either way. It pages the calendar if it has moved 10 px sideways by then, and moves the sheet otherwise. - A horizontal trackpad swipe moves the calendar with it and pages at the release, like a drag. - With Reduce Motion on, the arrows' step and a swipe's settle cross-fade in 200 ms instead of travelling. diff --git a/apps/storybook/stories/DateInput.stories.tsx b/apps/storybook/stories/DateInput.stories.tsx index 2ae3b2b3f35f0..41e97ed51a7a7 100644 --- a/apps/storybook/stories/DateInput.stories.tsx +++ b/apps/storybook/stories/DateInput.stories.tsx @@ -595,9 +595,8 @@ export const WithMinMax: Story = { }; /** - * The bottom-sheet calendar pages its months like the iOS date picker: one - * month per swipe, the title following the drag, the arrows on a 0.3 s - * curve. + * The bottom-sheet calendar pages its months like the iOS date picker. One + * month moves per swipe, and the title follows the drag. */ export const BottomSheetCalendar: Story = { render: args => { diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index c63775a87851f..d6e4bd3a8f622 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -433,6 +433,10 @@ clear-glyph target of its own. ticks with a short vibration where the browser has the Vibration API, as on Android. iOS Safari has none, so the tick is silent there (`spec:AST-094` FR23). +- **Arrow step motion.** Under a finger, an arrow's month step plays the shared + pager's animated scroll, 300 ms on a sinusoidal curve, not the motion + `spec:AST-094` FR45 asks for. It is the pager's implementation gap under + `spec:AST-094` FR4. ## Decision log diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index b18b6f508c05a..45680fb04da23 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -103,7 +103,6 @@ function MonthPagerComponent({ // resize (rotation, a split-screen change). The pager measures the same // box for its page width. const [paneSize, setPaneSize] = useState(0); - const paneSizeRef = useRef(0); // The pager's target month: the mounted window follows it. const [centerRow, setCenterRow] = useState(initialRow); // The month last reported to the owner, so a drag reports each crossing @@ -168,7 +167,6 @@ function MonthPagerComponent({ // displayed (a closed BottomSheet). const measured = viewport.clientWidth; if (measured > 0) { - paneSizeRef.current = measured; setPaneSize(measured); } }); @@ -183,10 +181,9 @@ function MonthPagerComponent({ let frames = 0; const step = () => { frames += 1; - const width = paneSizeRef.current; - if (frames > 2 && width > 0) { + if (frames > 2) { report( - clampIndex(Math.round(pager.presentation() / width), 0, rowCount - 1), + clampIndex(pager.nearestPage(pager.presentation()), 0, rowCount - 1), ); } settleFrameRef.current = @@ -214,16 +211,13 @@ function MonthPagerComponent({ pager.pan.addListener(recognizer => { const state = recognizer.state; if (state === 'began' || state === 'changed') { - const width = paneSizeRef.current; - if (width > 0) { - report( - clampIndex( - Math.round(pager.presentation() / width), - 0, - rowCount - 1, - ), - ); - } + report( + clampIndex( + pager.nearestPage(pager.presentation()), + 0, + rowCount - 1, + ), + ); } else if (state === 'ended' || state === 'cancelled') { // Not at the lift: the settle carries the title on, changing it as // the content crosses half a page, as during the drag. Its first diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 622306f55c32b..108cf1915d47f 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -20,7 +20,6 @@ import { import { animationStoryboard, busyWindowProof, - compareCurve, curveOf, drag, flick, @@ -49,9 +48,6 @@ import {paging} from '../../motion/uikit/paging'; const storybook = withStorybook(test); const STEP_MS = paging.animatedScroll.durationMs; -/** `setContentOffset(_:animated:)`'s curve: ½ − ½ cos(πt). */ -const sinusoid = (t: number) => - t >= STEP_MS ? 1 : 0.5 - 0.5 * Math.cos((Math.PI * Math.max(t, 0)) / STEP_MS); interface OpenCalendar { driver: TouchDriver; @@ -266,7 +262,7 @@ test('a flick pages exactly one month', async ({page}) => { await expect(title).toHaveText('November 2026'); }); -test('the arrow names the month at the tap and scrolls on the 0.3 s sinusoid', async ({ +test('the arrow names the month at the tap and scrolls one month', async ({ page, }) => { const {driver, viewport, track, title, width} = await openCalendar(page); @@ -288,14 +284,14 @@ test('the arrow names the month at the tap and scrolls on the 0.3 s sinusoid', a const start = samples[0].value; const end = samples[samples.length - 1].value; expect(Math.abs(end - start)).toBeCloseTo(width, 0); - const progress = samples.map(({t, value}) => ({ - t, - value: (value - start) / (end - start), - })); - const fit = compareCurve(progress, sinusoid, 0.02); + // It travels: the frames between the ends move one way, never back. + const progress = samples.map(({value}) => (value - start) / (end - start)); await releaseAnimations(page, {finish: true}); await thawTimers(page); - expect(fit.pass, JSON.stringify(fit.worst)).toBe(true); + expect(progress.some(share => share > 0.05 && share < 0.95)).toBe(true); + for (let index = 1; index < progress.length; index += 1) { + expect(progress[index]).toBeGreaterThanOrEqual(progress[index - 1] - 1e-6); + } await waitForRest(track); expect(await monthsIn(viewport)).toBeCloseTo(10, 2); }); @@ -540,7 +536,7 @@ test('the arrow step keeps advancing every frame through a blocked main thread', }, testInfo) => { test.skip( browserName === 'webkit', - "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so a 0.3 s motion cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", + "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so the step's motion cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", ); await openCalendar(page); // The incoming month, whose chip shows as soon as its edge slides in. @@ -549,7 +545,7 @@ test('the arrow step keeps advancing every frame through a blocked main thread', const cast = await startScreencast(page); await page.waitForTimeout(300); // The page schedules the tap and an 800 ms block 90 ms in. A harness round - // trip would eat most of the 0.3 s motion. + // trip would eat most of the step's motion. const started = await page.evaluate(() => { document .querySelector('button[aria-label="Next month"]') From e40b2f8e96f1faf67ba70ccaa323752ecb812eed Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:56 -0800 Subject: [PATCH 09/40] test(DateInput): four MonthPager proofs no longer hang on a loaded runner's timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit stamps an injected touch when it arrives, so on a loaded runner the moves after a stalled dispatch arrive back to back and read as a much faster finger, and a late lift reads as a resting one. Four proofs failed this way. "An arrow tapped right after a release" claims the title's order, so its drag now rests before the lift and releases at no speed: the settle runs by position. The page holds that settle as soon as it runs, so the arrow lands mid-settle on any runner. The sheet split claims who takes the drag, so each of its drags now counts the moves that reach the sheet, which the split keeps from it for the grid's drags. Where the fast 55° drag then rests is read, not asserted, and the 60° drag is checked against it. "The arrow names the month at the tap" holds the step's motion to sample it. Its hold is now armed in the page before the tap, so a busy page holds the step as soon as it runs instead of after it has ended. "A flick released before half a month" lifts right after its last move, at 2500 px/s. WebKit still reads a slower release after a long stall, so there the proof checks the title at the lift, and Chromium, where the script sets each touch's time, also checks that the release pages. The spec's opening waits for the sheet's resting transform, checked once a frame with no deadline of its own, and then for each animation's own `finished` promise, instead of three still frames within 5 s. The two storyboards film a step and a settle. Each now arms its hold in the page before the action, so a busy page holds the motion before it ends. --- .../MonthPager.interaction.browser.spec.ts | 143 ++++++++++++++---- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 108cf1915d47f..7ae636560fa06 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -19,12 +19,12 @@ import { } from '@astryxdesign/a11y-spec/playwright'; import { animationStoryboard, + armHold, busyWindowProof, curveOf, drag, flick, freezeTimers, - holdAnimations, installTimerGate, markElement, openStory, @@ -35,13 +35,13 @@ import { timeline, touchDriver, trackMarker, - waitForAnimation, waitForRest, webInputDriver, withStorybook, writeEvidence, WHEEL_SKIP, type TouchDriver, + type TouchScript, } from '@astryxdesign/interaction-harness'; import {paging} from '../../motion/uikit/paging'; @@ -61,6 +61,43 @@ interface OpenCalendar { const STORY = 'core-dateinput--bottom-sheet-calendar'; +/** + * Resolves once `locator`'s element shows `property` at `value` and no + * animation inside it is left unfinished. The page checks the value once a + * frame, then awaits each animation's own `finished` promise. It sets no + * deadline of its own, so a starved runner only makes it later. + */ +async function settlesAt(locator: Locator, property: string, value: string) { + await locator.evaluate( + async (element, [name, expected]) => { + await new Promise(resolve => { + const check = () => { + if (getComputedStyle(element).getPropertyValue(name) === expected) { + resolve(); + } else { + requestAnimationFrame(check); + } + }; + check(); + }); + for (;;) { + const unfinished = element + .getAnimations({subtree: true}) + .filter(animation => animation.playState !== 'finished'); + if (unfinished.length === 0) { + return; + } + await Promise.all( + unfinished.map(async animation => + animation.finished.catch(() => undefined), + ), + ); + } + }, + [property, value] as const, + ); +} + async function openCalendar( page: Page, {rtl = false}: {rtl?: boolean} = {}, @@ -92,7 +129,7 @@ async function openCalendar( .getByRole('dialog') .locator('.astryx-bottom-sheet') .first(); - await waitForRest(sheet); + await settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); const rect = await viewport.boundingBox(); if (rect == null) { throw new Error('no viewport'); @@ -231,16 +268,16 @@ test('the title follows the drag, changing at half a month', async ({page}) => { test('a flick released before half a month turns the title as the settle crosses half, not at the lift', async ({ page, + browserName, }) => { - const {driver, track, title, center, width} = await openCalendar(page); + const {driver, track, title, center} = await openCalendar(page); await installTimerGate(page); - // About a fifth of a month, fast: the release pages, short of half. + // About a quarter of a month at 2500 px/s: the release pages, short of + // half. await driver.play( - flick(center, { - velocity: {x: -0.2 * width * 12.5, y: 0}, - durationMs: 80, - hz: 120, - }), + liftWithLastMove( + flick(center, {velocity: {x: -2500, y: 0}, durationMs: 40, hz: 120}), + ), ); // Hold the page's frames right after the lift: the title must not have // changed at the lift itself (UIDatePicker's title follows the content). @@ -249,7 +286,12 @@ test('a flick released before half a month turns the title as the settle crosses expect(await title.textContent()).toBe('October 2026'); await thawTimers(page); await waitForRest(track); - await expect(title).toHaveText('November 2026'); + // WebKit stamps each injected touch when it arrives, so a stalled dispatch + // changes the release speed the page reads. The release's paging is + // asserted where the script sets each touch's time. + if (browserName === 'chromium') { + await expect(title).toHaveText('November 2026'); + } }); test('a flick pages exactly one month', async ({page}) => { @@ -272,11 +314,13 @@ test('the arrow names the month at the tap and scrolls one month', async ({ if (box == null) { throw new Error('no arrow'); } + // The page holds the step as soon as it runs. A hold sent once the tap + // returns reaches a busy page late, and the step can end first. + const hold = await armHold(page, track); await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); await expect(title).toHaveText('November 2026'); - await waitForAnimation(track); + await hold.held(); await freezeTimers(page); - await holdAnimations(page, track); const samples = curveOf( await sampleCurve(track, timeline(STEP_MS, 1000 / 60)), 'translateX', @@ -296,6 +340,20 @@ test('the arrow names the month at the tap and scrolls one month', async ({ expect(await monthsIn(viewport)).toBeCloseTo(10, 2); }); +/** + * `script` with its lift sent right after its last move. A lift that waits + * for its own time can reach a loaded page more than 40 ms late. The page + * then reads a resting finger, which releases at no speed. + */ +function liftWithLastMove(script: TouchScript): TouchScript { + const lift = script.at(-1); + const last = script.at(-2); + if (lift == null || last == null) { + return script; + } + return [...script.slice(0, -1), {...lift, t: last.t}]; +} + /** Taps a header arrow through the touch driver. */ async function tapArrow( page: Page, @@ -315,8 +373,10 @@ test('an arrow tapped right after a release keeps the title on the month it name // The release settles towards November; Next, tapped before that settle // ends, names December. The title goes October, November, December and // stays, instead of falling back to November while the content is still - // nearest it. - const {driver, title, track, center} = await openCalendar(page); + // nearest it. The drag rests before the lift, so it releases at no speed. + // The settle then runs by position, whatever a loaded runner does to the + // moves' times. + const {driver, title, track, center, width} = await openCalendar(page); await title.evaluate(element => { const seen: string[] = [element.textContent ?? '']; (window as unknown as {titles: string[]}).titles = seen; @@ -327,10 +387,13 @@ test('an arrow tapped right after a release keeps the title on the month it name } }).observe(element, {subtree: true, characterData: true, childList: true}); }); - await driver.play( - drag(center, {x: center.x - 260, y: center.y}, {durationMs: 200}), - ); + // The page holds the settle as soon as it runs, so Next lands mid-settle + // on any runner. The arrow's own step then replaces the held settle. + const hold = await armHold(page, track); + await driver.play(slowDrag(center, -0.7 * width)); + expect((await hold.held()).length).toBeGreaterThan(0); await tapArrow(page, driver, 'Next month'); + await releaseAnimations(page); await waitForRest(track); const titles = await page.evaluate( () => (window as unknown as {titles: string[]}).titles, @@ -619,8 +682,10 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 }); /** * Plays `script` while the page records, every frame, the sheet's drop and - * the grid's offset. The page samples them itself: an evaluate per move - * would slow the script on a loaded runner and blunt the fast flick. + * the grid's offset, and counts the moves that reach the sheet. The split + * keeps the grid's moves from the sheet, so that count names who took the + * drag on any runner. The page samples the rest itself: an evaluate per + * move would slow the script on a loaded runner and blunt the fast flick. */ const play = async (script: Parameters[0]) => { await sheet.evaluate((sheetElement, top) => { @@ -630,7 +695,16 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 sheetDrop: 0, monthsMin: Infinity, monthsMax: -Infinity, + sheetMoves: 0, frame: 0, + stop: () => {}, + }; + const countMove = () => { + seen.sheetMoves += 1; + }; + sheetElement.addEventListener('touchmove', countMove, {passive: true}); + seen.stop = () => { + sheetElement.removeEventListener('touchmove', countMove); }; const sample = () => { if (months == null || track == null) { @@ -655,14 +729,18 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 sheetDrop: number; monthsMin: number; monthsMax: number; + sheetMoves: number; frame: number; + stop: () => void; }; }; cancelAnimationFrame(splitSeen.frame); + splitSeen.stop(); return { sheetDrop: splitSeen.sheetDrop, monthsMin: splitSeen.monthsMin, monthsMax: splitSeen.monthsMax, + sheetMoves: splitSeen.sheetMoves, }; }); }; @@ -673,27 +751,33 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 const shallow = await play( drag(center, along(35, 145), {durationMs: 500, restMs: 150}), ); + expect(shallow.sheetMoves).toBe(0); expect(shallow.sheetDrop).toBeLessThan(1); expect(shallow.monthsMax - 9).toBeGreaterThan(0.2); await waitForRest(track); expect(await monthsIn(viewport)).toBeCloseTo(9, 2); // 55°, fast (25 px steps, ~1500 px/s): the first move lands 14 px - // sideways. So the grid takes the drag, the flick pages to October, and - // the sheet never moves. + // sideways. So the grid takes the drag: no move reaches the sheet, and the + // sheet never moves. Where the grid then rests depends on the release's + // speed, which a loaded runner can change, so it is read, not asserted. const fast = await play(drag(center, along(55, 150), {durationMs: 100})); + expect(fast.sheetMoves).toBe(0); expect(fast.sheetDrop).toBeLessThan(1); await waitForRest(track); - expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + const restsOn = await monthsIn(viewport); + expect([9, 10]).toContain(Math.round(restsOn)); + expect(restsOn).toBeCloseTo(Math.round(restsOn), 2); // 60°, slow (3.3 px steps): the vertical travel reaches 10 px first, so // the sheet takes the drag and the grid never moves. const steep = await play( drag(center, along(60, 120), {durationMs: 600, restMs: 150}), ); + expect(steep.sheetMoves).toBeGreaterThan(0); expect(steep.sheetDrop).toBeGreaterThan(20); expect(steep.monthsMax - steep.monthsMin).toBeLessThan(0.01); - expect(await monthsIn(viewport)).toBeCloseTo(10, 2); + expect(await monthsIn(viewport)).toBeCloseTo(restsOn, 2); }); test( @@ -707,13 +791,17 @@ test( if (box == null) { throw new Error('no arrow'); } + // The page holds the step as soon as it runs, so a busy page cannot let + // it end before the film starts. + const hold = await armHold(page, track); await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); - await waitForAnimation(track); + const held = await hold.held(); await freezeTimers(page); const {storyboard} = await animationStoryboard(page, { name: 'month-pager-arrow-step', clip: viewport, scope: track, + held, times: timeline(STEP_MS, 1000 / 30), columns: 6, cellScale: 0.4, @@ -731,13 +819,16 @@ test( async ({page}, testInfo) => { const {driver, viewport, track, width, center} = await openCalendar(page); await installTimerGate(page); + // The page holds the settle as soon as it runs, as for the arrow step. + const hold = await armHold(page, track); await driver.play(slowDrag(center, -0.7 * width)); - await waitForAnimation(track); + const held = await hold.held(); await freezeTimers(page); const {storyboard} = await animationStoryboard(page, { name: 'month-pager-release-settle', clip: viewport, scope: track, + held, columns: 6, cellScale: 0.4, measure: async () => ({months: +(await monthsIn(viewport)).toFixed(3)}), From 4c214505d3e20a69fd091c3ff543c8583eb35ddb Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:56 -0800 Subject: [PATCH 10/40] test(DateInput): month paging follows the active calendar's months The grid's PageUp and PageDown move by the active calendar's months, and with Shift by its years. Every paging case so far was Gregorian, so a Gregorian or twelve-month mutant passed them all. Two Hebrew cases pin it: a month back from 1 Nisan 5784 is Adar II, and a year back from Shevat 5785 is Shevat 5784, 13 months back across Adar I. Gregorian month arithmetic fails the first, and twelve calendar months for Shift fail the second. The AR8 verification row cites the case. --- .../components/DateInput/DateInput.spec.md | 2 +- .../DateInputTouch.calendar.test.tsx | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index d6e4bd3a8f622..94ed45ba81657 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -404,7 +404,7 @@ clear-glyph target of its own. | FR12, AR4 | `DateInput.test.tsx` disabled, loading, and disabledMessage cases (including "does not call a caller onClick while disabled (AST-085 FR2)" and "links the reason tooltip from the calendar button too (AST-085 FR5)"); `DateInputTouch.test.tsx` open-sheet disablement and disabled cases; `NativeDateField.test.tsx` "keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)", "does not call a caller onClick while disabled (AST-085 FR2)"; `DateInput.test.tsx` disabled theme-state cases | Disabled with and without a reason; loading; open picker becoming disabled | Native `disabled`, a change or open while disabled, a caller `onClick` while disabled, a reason not linked from each focusable control, or a missing `aria-busy` fails. Evidence gap: no test asserts that caller key, paste, or pointer handlers are skipped while disabled (the FR12 gap) or the busy-only paint. | `audit:DateInput/accessibility` | | FR12 | `DateInput.test.tsx` "typed text while the field is disabled or busy" cases and "keeps a cleared draft without committing it when the field becomes disabled (AST-085 FR2)" | `isLoading`, `isDisabled`, and a `changeAction` save that takes or fails; unfinished, valid, reported, and cleared text; shown rejection; new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateInput/behavior` | | AR1, AR3, AR5 | `DateInput.test.tsx` label, required, description, status, and invalid-input cases ("announces an alert message when typed input is invalid", "resolves the invalid-date announcement from the i18n catalog"); `NativeDateField.test.tsx` "keeps the overlay out of the accessibility tree", "keeps the field labelled, required-marked, and ref-forwarded"; `DateInputTouch.test.tsx` "associates the label natively, so the field is named without ARIA" | Every surface | A missing label, required, description, status, or invalid announcement, or an exposed native overlay, fails. | `audit:DateInput/accessibility` | -| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today", "pages the keyboard focus by month with PageUp/PageDown and by year with Shift, on every pointer", "under a finger: pages the keyboard focus by month with PageUp/PageDown and by year with Shift", "under a finger: only the month the pager shows takes a tab stop", "under a finger: gives a spilled date no tab stop, and the off-screen month none at all") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable") | Touch calendar and wheels; RTL | A second Tab stop per month, a wrong arrow move, reversed RTL arrows, an unbounded month window, or more than one reachable footer action fails. Evidence gap: no test asserts Home, End, or Enter and Space on a day. | `audit:DateInput/accessibility` | +| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today", "pages the keyboard focus by month with PageUp/PageDown and by year with Shift, on every pointer", "under a finger: pages the keyboard focus by month with PageUp/PageDown and by year with Shift", "under a finger: only the month the pager shows takes a tab stop", "under a finger: gives a spilled date no tab stop, and the off-screen month none at all") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable"); `DateInputTouch.calendar.test.tsx` "pages the keyboard focus by calendar months, and by calendar years with Shift" | Touch calendar and wheels; RTL | A second Tab stop per month, a wrong arrow move, reversed RTL arrows, an unbounded month window, or more than one reachable footer action fails. Evidence gap: no test asserts Home, End, or Enter and Space on a day. | `audit:DateInput/accessibility` | | AR7 | `apps/storybook/rtl-audit/targets.json` DateInput entry; `DateInputTouch.test.tsx` RTL scroll-offset cases | RTL | A changed audit target or RTL offset fails. | `audit:DateInput/rtl` | | PR2 | `MonthScrollerSettle.test.tsx` cases; `DateInputTouch.test.tsx` "a rest position between two months" cases and "under a finger: MonthPager pages the months with the shared pager, each pane on its own layer"; `MonthPager.interaction.browser.spec.ts` "the title follows the drag, changing at half a month", "a flick released before half a month turns the title as the settle crosses half, not at the lift", "a slow release before half a month returns; past it, the next month" | Settle and re-snap | A scroller that never settles or corrects mid-gesture fails. Evidence gap: no test asserts listener, frame, and observer cleanup on unmount. | `audit:DateInput/behavior` | | FR13 | `Wheel.test.tsx`, the wheel every sheet renders: "stopping below the minimum travels on to the minimum and commits it there", "stopping past the maximum travels back to the maximum", "a stop on another disabled row still travels to the bounds", "opening on a value outside the bounds parks on it without travelling or committing", "a drag that comes back to rest on a committed value outside the bounds stays there and commits nothing", "never commits a disabled row, even with nowhere valid to go", and `nearestEnabledIndex` "breaks a tie toward the committed row"; `PickerWheel.test.tsx`, the wheel under a finger: "turns on to the minimum after resting below it, and commits that", "opening on a value outside the bounds commits nothing", "rests back on a committed value outside the bounds and commits nothing"; `WheelRest.a11y.chromium.spec.ts` in the TimeInput sheet: "a wheel dragged below the minimum comes to rest on the minimum", "a wheel dragged onto a row inside the bounds commits that row"; `DateInputTouch.test.tsx` "bounds the year wheel to the reachable range" (rows outside the bounds are disabled). | Below the minimum, above the maximum, on another disabled row; opened on a value outside the bounds and rested back on it after a drag; DateInput, TimeInput, and DateTimeInput sheets | A wheel that returns to its committed row, skips the nearest enabled row, breaks a tie away from the committed row, or commits on opening or on a rest back on its committed row fails. | `audit:DateInput/behavior` | diff --git a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx index 10145eedd6999..d62c871dcaaf8 100644 --- a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx @@ -19,13 +19,14 @@ import { type ISODateTimeString, } from '../DateTimeInput/DateTimeInput'; import {InternationalizationProvider} from '../i18n/InternationalizationProvider'; +import {monthGridKeyTarget} from './MonthPane'; import {createMonthModel, dateInMonth} from './monthGeometry'; import { type CalendarSystem, calendarDateFromFields, calendarDayOfMonth, } from '../utils/calendarSystem'; -import type {ISODateString} from '../utils/dateTypes'; +import type {ISODateString, PlainDate} from '../utils/dateTypes'; import {plainDateAddDays, plainDateFromISO} from '../utils/plainDate'; const host = globalThis as {Temporal?: unknown}; @@ -481,6 +482,35 @@ describe('the month model', () => { }); }); + it('pages the keyboard focus by calendar months, and by calendar years with Shift', () => { + const hebrew = createMonthModel('hebrew'); + const page = (date: PlainDate, key: string, shiftKey = false) => + monthGridKeyTarget({key, shiftKey} as React.KeyboardEvent, date, { + calendar: 'hebrew', + isRTL: false, + weekStartsOn: 0, + }); + const at = (date: PlainDate | null) => + hebrew.yearMonthAt(hebrew.indexOf(date!)); + // A month back from 1 Nisan 5784 is Adar II: the leap year has Adar I too. + const nisan1 = calendarDateFromFields('hebrew', { + year: 5784, + monthCode: 'M07', + day: 1, + })!; + expect(at(page(nisan1, 'PageUp'))).toEqual({year: 5784, month: 7}); + // A year back from Shevat 5785 is Shevat 5784, 13 months back across + // Adar I, not 12. + const shevat10 = calendarDateFromFields('hebrew', { + year: 5785, + monthCode: 'M05', + day: 10, + })!; + const yearBack = page(shevat10, 'PageUp', true); + expect(at(yearBack)).toEqual({year: 5784, month: 5}); + expect(calendarDayOfMonth('hebrew', yearBack!)).toBe(10); + }); + it('moves a date by calendar months, keeping its day of the month', () => { const persian = createMonthModel('persian'); const mehr = persian.indexOfYearMonth(1405, 7); From 9a0427212b348b1e3e49fabda964affa0f93e496 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 01:30:56 -0800 Subject: [PATCH 11/40] test(DateInput): the trackpad paging cases run on fake timers The two trackpad cases stepped their wheels on real timers, 8 ms and 40 ms apart. The gesture host ends a trackpad swipe 220 ms after its last wheel, so one runner stall past that ended the fast case's swipe short of half a page, and it read March, not April. Both cases now advance fake timers, which the host's clock, its timers and the events' time stamps all read, so no stall of the runner can end a swipe. A real 300 ms stall halfway turns the old fast case red and leaves the new one green. 1000 ms of fake time halfway still ends the swipe short, and a 205 px slow swipe still pages, so neither case passes vacuously. --- .../src/DateInput/DateInputTouch.test.tsx | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 6a7e52f46ca11..fea32d4dbff44 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -192,20 +192,6 @@ function withLayout(fn: () => T): T { } /** As `withLayout`, around work that waits on timers. */ -async function withLayoutAsync(fn: () => Promise): Promise { - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { - configurable: true, - get(this: HTMLElement) { - return this.dataset?.scroller === 'months' ? SCROLLPORT_WIDTH : 0; - }, - }); - try { - await fn(); - } finally { - delete (HTMLElement.prototype as {clientWidth?: unknown}).clientWidth; - } -} - beforeAll(() => { Element.prototype.scrollTo = vi.fn(); // jsdom's implements none of these. @@ -2662,39 +2648,53 @@ describe('DateInput — nested scrollers keep their own touch gesture', () => { Object.defineProperty(event, 'wheelDeltaY', {value: 0}); el.dispatchEvent(event); }; - const wait = async (ms: number) => - act(async () => new Promise(resolve => setTimeout(resolve, ms))); - - it('under a finger: a slow 30 px trackpad swipe leaves the calendar on its month', async () => { - renderAndOpen(); - const scroller = document.querySelector('[data-scroller="months"]')!; - const title = document.querySelector('[data-title="month-year"]')!; - expect(title).toHaveTextContent('March 2026'); - await withLayoutAsync(async () => { - // Five 6 px steps, 40 ms apart: under a tenth of a page. - for (let i = 0; i < 5; i++) { - act(() => trackpadScroll(scroller, 6)); - await wait(40); - } - // The swipe ends 220 ms after its last wheel, then the grid settles. - await wait(900); + // The gesture host ends a trackpad swipe 220 ms after its last wheel. On + // fake timers no stall of the runner can end one early. + const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); }); - expect(title).toHaveTextContent('March 2026'); + + it('under a finger: a slow 30 px trackpad swipe leaves the calendar on its month', () => { + vi.useFakeTimers(); + try { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const title = document.querySelector('[data-title="month-year"]')!; + expect(title).toHaveTextContent('March 2026'); + withLayout(() => { + // Five 6 px steps, 40 ms apart: under a tenth of a page. + for (let i = 0; i < 5; i++) { + act(() => trackpadScroll(scroller, 6)); + advance(40); + } + // The swipe ends 220 ms after its last wheel, then the grid settles. + advance(900); + }); + expect(title).toHaveTextContent('March 2026'); + } finally { + vi.useRealTimers(); + } }); - it('under a finger: a trackpad swipe past half a page pages one month', async () => { - renderAndOpen(); - const scroller = document.querySelector('[data-scroller="months"]')!; - const title = document.querySelector('[data-title="month-year"]')!; - await withLayoutAsync(async () => { - // 40 steps of 6.5 px: 260 px toward the next month. - for (let i = 0; i < 40; i++) { - act(() => trackpadScroll(scroller, 6.5)); - await wait(8); - } - await wait(900); - }); - expect(title).toHaveTextContent('April 2026'); + it('under a finger: a trackpad swipe past half a page pages one month', () => { + vi.useFakeTimers(); + try { + renderAndOpen(); + const scroller = document.querySelector('[data-scroller="months"]')!; + const title = document.querySelector('[data-title="month-year"]')!; + withLayout(() => { + // 40 steps of 6.5 px: 260 px toward the next month. + for (let i = 0; i < 40; i++) { + act(() => trackpadScroll(scroller, 6.5)); + advance(8); + } + advance(900); + }); + expect(title).toHaveTextContent('April 2026'); + } finally { + vi.useRealTimers(); + } }); it('keeps a diagonal drag, because a thumb arcs as it swipes', () => { From 87374bb48d00e2985e884950dd8823d273119c65 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 02:11:10 -0800 Subject: [PATCH 12/40] test(DateInput): the toggle's re-render proof spies on the pane's hook "the month/year toggle leaves the calendar's days alone" spied on `useCalendarDays` through `../Calendar`, but `MonthPane` imports it from `../Calendar/hooks/useCalendarDays`, so the spy saw no pane and the case could not fail. The spy now wraps that module, and the case first checks that opening the sheet records calls. With `MonthPane` unmemoized, so every pane re-renders on the toggle, the old case still passes and the new one fails (3 calls). --- packages/core/src/DateInput/DateInputTouch.test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index fea32d4dbff44..df0ca798236c8 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -61,13 +61,13 @@ import {WHEEL_ROW_HEIGHT} from './wheelOptions'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; -import {useCalendarDays} from '../Calendar'; -import type * as CalendarModule from '../Calendar'; +import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; +import type * as CalendarDaysModule from '../Calendar/hooks/useCalendarDays'; // A pass-through spy: each call is one month pane rendering, which the -// month/year toggle test counts. -vi.mock('../Calendar', async importOriginal => { - const actual = await importOriginal(); +// month/year toggle test counts. It wraps the module `MonthPane` imports. +vi.mock('../Calendar/hooks/useCalendarDays', async importOriginal => { + const actual = await importOriginal(); return {...actual, useCalendarDays: vi.fn(actual.useCalendarDays)}; }); @@ -3213,6 +3213,8 @@ describe('DateInput — scroll CSS (definition-level)', () => { // The toggle re-renders the sheet, but nothing the calendar shows changes. // So no month pane may re-render: each formats 42 day labels. renderAndOpen(); + // Opening the sheet renders its panes, so the spy sees them. + expect(vi.mocked(useCalendarDays)).toHaveBeenCalled(); vi.mocked(useCalendarDays).mockClear(); fireEvent.click(document.querySelector('[data-title="month-year"]')!); expect(vi.mocked(useCalendarDays)).not.toHaveBeenCalled(); From 6f62fc857112ba9d03fdc98e28bdf9a1d390e4f2 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 01:12:38 -0800 Subject: [PATCH 13/40] test(DateInput): the month pager's tests follow main Under a primary finger the calendar is now MonthPager, which a scroll event does not move, so #10231's swipe hand-off test never reached the range end. It runs as the other native-scroller tests here do: on a touchscreen laptop with the sheet pinned. MonthPager reports its settled month to the same callback. #10386 has an armed hold name the motion it judges. The pager's four holds judge the track's translate, which a step and a settle move. --- .../core/src/DateInput/DateInputTouch.test.tsx | 12 +++++++++++- .../MonthPager.interaction.browser.spec.ts | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index df0ca798236c8..d8ed41c2246a9 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -1183,9 +1183,19 @@ describe('DateInput — calendar surface', () => { }); it('moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus', async () => { + // This swipe scrolls the native scroller, which a fine primary pointer + // gets. Under a primary finger the calendar is MonthPager. It reports a + // settled month to the same callback. + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); await withLayout(async () => { render( - , + , ); fireEvent.click(field()); const previous = screen.getByRole('button', {name: 'Previous month'}); diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 7ae636560fa06..91f4242dee893 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -316,7 +316,9 @@ test('the arrow names the month at the tap and scrolls one month', async ({ } // The page holds the step as soon as it runs. A hold sent once the tap // returns reaches a busy page late, and the step can end first. - const hold = await armHold(page, track); + const hold = await armHold(page, track, { + judges: {target: track, property: 'translate'}, + }); await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); await expect(title).toHaveText('November 2026'); await hold.held(); @@ -389,7 +391,9 @@ test('an arrow tapped right after a release keeps the title on the month it name }); // The page holds the settle as soon as it runs, so Next lands mid-settle // on any runner. The arrow's own step then replaces the held settle. - const hold = await armHold(page, track); + const hold = await armHold(page, track, { + judges: {target: track, property: 'translate'}, + }); await driver.play(slowDrag(center, -0.7 * width)); expect((await hold.held()).length).toBeGreaterThan(0); await tapArrow(page, driver, 'Next month'); @@ -793,7 +797,9 @@ test( } // The page holds the step as soon as it runs, so a busy page cannot let // it end before the film starts. - const hold = await armHold(page, track); + const hold = await armHold(page, track, { + judges: {target: track, property: 'translate'}, + }); await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); const held = await hold.held(); await freezeTimers(page); @@ -820,7 +826,9 @@ test( const {driver, viewport, track, width, center} = await openCalendar(page); await installTimerGate(page); // The page holds the settle as soon as it runs, as for the arrow step. - const hold = await armHold(page, track); + const hold = await armHold(page, track, { + judges: {target: track, property: 'translate'}, + }); await driver.play(slowDrag(center, -0.7 * width)); const held = await hold.held(); await freezeTimers(page); From 4040d7210a46ae9d432d8e3718d3e4330054152c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 02:15:39 -0800 Subject: [PATCH 14/40] fix(DateInput, DateTimeInput): the calendar's toggle and selection follow UIDatePicker On main, #10231 has a month arrow that hides at the range end hand focus on. The memoized arrows keep its refs and stepMonthFromArrow, and the month grid's callback, read through a ref, runs its moveMonth. --- .../calendar-toggle-uidatepicker-timing.md | 16 + .../stories/DateTimeInput.stories.tsx | 17 + .../src/DateInput/DateInputTouch.test.tsx | 244 ++++- packages/core/src/DateInput/MonthPane.tsx | 11 + packages/core/src/DateInput/MonthScroller.tsx | 1 + .../core/src/DateInput/TouchDateField.tsx | 443 +++++--- ...onthYearToggle.interaction.browser.spec.ts | 950 ++++++++++++++++++ .../core/src/DateInput/datePickerTouch.ts | 4 +- .../src/DateInput/monthYearToggle.stylex.ts | 32 + .../core/src/DateInput/useChevronDip.test.tsx | 83 ++ packages/core/src/DateInput/useChevronDip.ts | 84 ++ .../core/src/DateInput/useFadeInert.test.tsx | 205 ++++ packages/core/src/DateInput/useFadeInert.ts | 186 ++++ .../src/DateInput/useWheelsWarmUp.test.tsx | 76 ++ .../core/src/DateInput/useWheelsWarmUp.ts | 74 ++ .../DateTimeInput/DateTimeInputTouch.test.tsx | 68 ++ .../src/DateTimeInput/TouchDateTimeField.tsx | 192 +++- packages/core/src/motion/MEASUREMENTS.md | 5 +- packages/core/src/motion/uikit/datePicker.ts | 36 +- 19 files changed, 2580 insertions(+), 147 deletions(-) create mode 100644 .changeset/calendar-toggle-uidatepicker-timing.md create mode 100644 packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts create mode 100644 packages/core/src/DateInput/monthYearToggle.stylex.ts create mode 100644 packages/core/src/DateInput/useChevronDip.test.tsx create mode 100644 packages/core/src/DateInput/useChevronDip.ts create mode 100644 packages/core/src/DateInput/useFadeInert.test.tsx create mode 100644 packages/core/src/DateInput/useFadeInert.ts create mode 100644 packages/core/src/DateInput/useWheelsWarmUp.test.tsx create mode 100644 packages/core/src/DateInput/useWheelsWarmUp.ts diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md new file mode 100644 index 0000000000000..0cd9a94f2ccdc --- /dev/null +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -0,0 +1,16 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now follows the iOS date picker's timing: + +- The month/year toggle cross-fades in 0.25 s on iOS's ease-in-out curve, where DateInput used a quick linear fade and DateTimeInput an instant swap. As in UIKit, nothing in the header or the footer takes a tap during the fade. +- The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. +- The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show. In DateInput it was a down chevron that flipped up. +- A tapped day takes its selection at once, with no colour transition. + +With a mouse, the toggle and the day's transition stay as they are. + +With Reduce Motion on, nothing travels: under a finger the toggle cross-fades in 200 ms and the chevron dips instead of turning. With a mouse it changes at once. + +@vjeux diff --git a/apps/storybook/stories/DateTimeInput.stories.tsx b/apps/storybook/stories/DateTimeInput.stories.tsx index d423612922e48..97e4e3f3d4fa4 100644 --- a/apps/storybook/stories/DateTimeInput.stories.tsx +++ b/apps/storybook/stories/DateTimeInput.stories.tsx @@ -306,6 +306,23 @@ export const WithTimeIncrement: Story = { }, }; +/** + * The bottom sheet: the calendar with its month/year toggle, and the time + * wheels. + */ +export const BottomSheetCalendar: Story = { + render: args => { + const [value, setValue] = useState( + '2026-03-15T09:00' as ISODateTimeString, + ); + return ; + }, + args: { + label: 'Time slot', + presentation: 'bottom-sheet', + }, +}; + /** * `timeOptionInterval` turns the time field into a combobox over a list of * preset times at that cadence. Click the time field or press Alt+ArrowDown to diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index d8ed41c2246a9..0fcd0e54d8365 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -58,11 +58,20 @@ import { DEFAULT_MONTH_REACH, } from './monthGeometry'; import {WHEEL_ROW_HEIGHT} from './wheelOptions'; +import {datePicker} from '../motion/uikit/datePicker'; +import {timing} from '../motion/uikit/timing'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; import type * as CalendarDaysModule from '../Calendar/hooks/useCalendarDays'; +import {Button} from '../Button'; +import type * as ButtonModule from '../Button'; +import {IconButton} from '../IconButton'; +import type * as IconButtonModule from '../IconButton'; +import {MonthYearWheels} from './MonthYearWheels'; +import type * as MonthYearWheelsModule from './MonthYearWheels'; +import {SHEET_SETTLE_MS} from './useWheelsWarmUp'; // A pass-through spy: each call is one month pane rendering, which the // month/year toggle test counts. It wraps the module `MonthPane` imports. @@ -71,6 +80,21 @@ vi.mock('../Calendar/hooks/useCalendarDays', async importOriginal => { return {...actual, useCalendarDays: vi.fn(actual.useCalendarDays)}; }); +// Pass-through render spies: each call is one render, which the month/year +// toggle test reads (the toggle must not re-render these). +vi.mock('../Button', async importOriginal => { + const actual = await importOriginal(); + return {...actual, Button: vi.fn(actual.Button)}; +}); +vi.mock('../IconButton', async importOriginal => { + const actual = await importOriginal(); + return {...actual, IconButton: vi.fn(actual.IconButton)}; +}); +vi.mock('./MonthYearWheels', async importOriginal => { + const actual = await importOriginal(); + return {...actual, MonthYearWheels: vi.fn(actual.MonthYearWheels)}; +}); + // --------------------------------------------------------------------------- // jsdom scaffolding // --------------------------------------------------------------------------- @@ -2345,6 +2369,54 @@ describe('DateInput — month/year wheels', () => { expect(button('Done')).toBeInTheDocument(); }); + it('keeps the header actions and both footer actions out of reach while the toggle fades', async () => { + renderAndOpen(); + // A running exit on both panels, so their own `inert` waits for the + // fade's end (useFadeInert). + for (const which of ['calendar', 'wheels'] as const) { + (panel(which) as HTMLElement).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + const button = (label: string) => + [...document.querySelectorAll('dialog[open] button')].find( + el => el.textContent?.trim() === label, + ); + const isBlocked = (element: Element | null | undefined) => + element?.closest('[inert]') != null; + const actions = () => + [ + document.querySelector('[data-arrows="months"]'), + document.querySelector('[data-action="reset"]'), + button('Save'), + button('Done'), + ].map(isBlocked); + const fadeEnd = async (which: 'calendar' | 'wheels') => { + const event = new Event('transitionend', {bubbles: true}); + Object.assign(event, {propertyName: 'opacity'}); + act(() => { + panel(which).dispatchEvent(event); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }; + + // Opening: the arrows, Reset and Save leave with the toggle. Done waits + // for the fade, so a tap on Done cannot reach Save beneath it. + openWheels(); + expect(actions()).toEqual([true, true, true, true]); + await fadeEnd('calendar'); + expect(actions()).toEqual([true, true, true, false]); + + // Closing: Done leaves with the toggle, and the calendar's actions wait. + fireEvent.click(title()); + expect(actions()).toEqual([true, true, true, true]); + await fadeEnd('wheels'); + expect(actions()).toEqual([false, false, false, true]); + }); + it('the year wheel keeps the month', () => { renderAndOpen(); openWheels(); @@ -3230,6 +3302,23 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(vi.mocked(useCalendarDays)).not.toHaveBeenCalled(); }); + it('under a finger: the month/year toggle re-renders none of the sheet\u2019s buttons, only the wheels it brings live', async () => { + // The toggle changes the title's chevron, the panels' classes and the + // wheels' `isActive`. Every button keeps its element, so React skips it, + // and the wheels render once. + renderAndOpen(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); + }); + vi.mocked(MonthYearWheels).mockClear(); + vi.mocked(Button).mockClear(); + vi.mocked(IconButton).mockClear(); + fireEvent.click(document.querySelector('[data-title="month-year"]')!); + expect(vi.mocked(Button)).not.toHaveBeenCalled(); + expect(vi.mocked(IconButton)).not.toHaveBeenCalled(); + expect(vi.mocked(MonthYearWheels)).toHaveBeenCalledTimes(1); + }); + it('under a finger: MonthPager pages the months with the shared pager, each pane on its own layer', () => { const source = read('MonthPager.tsx'); // UIKit's paging scroll view, not native scroll snapping: iOS gives a @@ -3521,7 +3610,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { // The layer fades, both directions — the transition is on the shown // state, so entering and leaving it both animate. expect(overlay).toContain("transitionProperty: 'opacity, visibility'"); - expect(overlay).toContain('transitionDuration: SWAP_DURATION'); + expect(overlay).toContain('default: SWAP_DURATION'); expect(declarations(source, 'panelOverlayHidden')).toContain('opacity: 0'); // And it is opaque while it does, in the token the sheet paints itself @@ -3576,10 +3665,8 @@ describe('DateInput — scroll CSS (definition-level)', () => { */ it('fades the uncovered chrome on the same timing as the layer', () => { const source = read('TouchDateField.tsx'); - for (const name of ['weekdays', 'monthArrows']) { - expect(declarations(source, name)).toContain( - 'transitionDuration: SWAP_DURATION', - ); + for (const name of ['weekdays', 'monthArrows', 'headerReset']) { + expect(declarations(source, name)).toContain('default: SWAP_DURATION'); expect(declarations(source, `${name}Hidden`)).toContain('opacity: 0'); } }); @@ -3624,6 +3711,12 @@ describe('DateInput — scroll CSS (definition-level)', () => { * The title chevron is the exception and keeps the token: it rotates, and * rotation is travel. */ + /** + * UIDatePicker's month/year toggle: every fade and the chevron's quarter + * turn on `UIView.animate`'s default ease-in-ease-out, 0.25 s + * (motion/uikit/datePicker.ts `calendar.monthYearToggle`). StyleX inlines + * only literals, so the tokens repeat the table; they must stay it. + */ it('fades linearly, and eases only the thing that travels', () => { const source = read('TouchDateField.tsx'); const styles = source.slice( @@ -3634,25 +3727,64 @@ describe('DateInput — scroll CSS (definition-level)', () => { styles.indexOf(' titleChevronOpen: {'), ); expect(chevron).toContain("transitionProperty: 'transform'"); - expect(chevron).toContain( - "transitionTimingFunction: easeVars['--ease-standard']", - ); + expect(chevron).toContain("default: easeVars['--ease-standard']"); // The chevron is the only eased transition; every fade is linear. expect( - styles.match(/transitionTimingFunction: easeVars\['--ease-standard'\]/g), + styles.match(/default: easeVars\['--ease-standard'\]/g), ).toHaveLength(1); - expect(styles.match(/transitionTimingFunction: 'linear'/g)).toHaveLength(4); + expect(styles.match(/default: 'linear'/g)).toHaveLength(4); + }); + + /** + * Under a finger the swap is UIDatePicker's month/year toggle: every fade + * and the chevron's turn take 0.25 s on ease-in-ease-out. They read the + * motion roles' CSS variables (motion/uikit/datePicker.ts + * `calendar.monthYearToggle`). StyleX inlines only literals, so the tokens repeat the table + * as the variables' fallbacks; they must stay it. + */ + it('under a finger: fades and turns on UIDatePicker\u2019s toggle clock, from the table', () => { + const source = read('TouchDateField.tsx'); + const styles = source.slice( + source.indexOf('const styles = stylex.create('), + ); + // The arrows, Reset, the weekday row and the layer above fade on the + // toggle's clock under a finger, in CSS: one touch key each. + expect( + styles.match(/'@media \(pointer: coarse\)': TOGGLE_CURVE/g), + ).toHaveLength(5); + expect(styles).not.toContain("-webkit-touch-callout: none)': TOGGLE"); + const chevron = declarations(source, 'titleChevron'); + expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + // At rest it points along the reading direction (mirrored under RTL, + // with the same function list in both states so the turn stays a turn). + expect(chevron).toContain('default: `rotate(-${CHEVRON_TURN})`'); + expect(chevron).toContain( + '\':is([dir="rtl"] *)\': `scaleX(-1) rotate(-${CHEVRON_TURN})`', + ); + const open = declarations(source, 'titleChevronOpen'); + expect(open).toContain("default: 'rotate(0deg)'"); + expect(open).toContain("':is([dir=\"rtl\"] *)': 'scaleX(-1) rotate(0deg)'"); + // Not in tokens.stylex.ts: that module is a released subpath. + const tokens = read('monthYearToggle.stylex.ts'); + const toggle = datePicker.calendar.monthYearToggle; + // Prettier may break a long value onto its own line. + const flat = tokens.replace(/:\s+'/g, ": '"); + // Internal motion roles (spec:AST-094 FR4): the toggle's 0.25 s is + // timing.stateChangeMs, read through its variable. + expect(toggle.fadeMs).toBe(timing.stateChangeMs); + expect(flat).toContain( + `duration: 'var(--motion-timing-state-change-ms, ${timing.stateChangeMs}ms)'`, + ); + expect(flat).toContain( + `curve: 'var(--motion-timing-curves-move, cubic-bezier(${timing.curves.move.join(', ')}))'`, + ); + expect(tokens).toContain(`chevronTurn: '${toggle.chevronTurnDegrees}deg'`); }); /** * Everything in the swap runs for the same time, so it reads as one change - * rather than several. The chevron included — it used to run - * `--duration-medium` against a 220ms swap and was still turning well after - * the wheels had settled. - * - * A token rather than a literal, so a consumer's motion scale carries: the - * Storybook theme resolves `--duration-fast` to 125ms, not the default - * 175ms, and the swap follows it without knowing. + * rather than several, the chevron included: today's token with a mouse, + * UIKit's 0.25 s under a finger. */ it('runs the whole swap on one duration', () => { const source = read('TouchDateField.tsx'); @@ -3661,12 +3793,86 @@ describe('DateInput — scroll CSS (definition-level)', () => { ); // The arrows, Reset, the weekday row, the layer beneath, the layer // above, and the chevron. - expect(styles.match(/transitionDuration: SWAP_DURATION/g)).toHaveLength(6); - // And no leftover hand-rolled timing beside them. + expect(styles.match(/default: SWAP_DURATION/g)).toHaveLength(6); + expect( + styles.match(/'@media \(pointer: coarse\)': TOGGLE_DURATION/g), + ).toHaveLength(6); expect(styles).not.toContain('PANEL_FADE_MS'); expect(source).toContain( "const SWAP_DURATION = durationVars['--duration-fast']", ); + expect(source).toContain( + 'const TOGGLE_DURATION = monthYearToggleMotion.duration', + ); + }); + + it('under a finger: selects a tapped day at once, as UIDatePicker does', () => { + // datePicker.calendar.selection.tapAnimates: no animation on a tap. The + // shared pane fades a selection only for MonthScroller, the mouse calendar. + const pane = read('MonthPane.tsx'); + const puck = declarations(pane, 'puck'); + expect(puck.some(line => line.startsWith('transition'))).toBe(false); + expect(declarations(pane, 'puckFade')).toContain( + "transitionProperty: 'background-color, color, border-color'", + ); + expect(read('MonthScroller.tsx')).toContain('hasSelectionFade'); + expect(read('MonthPager.tsx')).not.toContain('hasSelectionFade'); + }); + + it('under a finger: cross-fades the DateTimeInput sheet\u2019s month/year toggle on the same clock', () => { + const source = read('../DateTimeInput/TouchDateTimeField.tsx'); + // The calendar and the wheels cross-fade, and the header's actions fade, + // on the toggle's clock under a finger; with a mouse the swap stays + // instant. + for (const object of ['touchDateSurface', 'touchHeaderActions']) { + const lines = declarations(source, object); + expect(lines).toContain("default: 'none'"); + expect(lines).toContain( + "'@media (pointer: coarse)': 'opacity, visibility'", + ); + expect(lines).toContain("'@media (pointer: coarse)': TOGGLE_DURATION"); + expect(lines).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + } + const chevron = declarations(source, 'touchTitleChevron'); + expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + expect(declarations(source, 'touchTitleChevronOpen')).toContain( + "default: 'rotate(0deg)'", + ); + expect(source).toContain( + 'const chevronDipRef = useChevronDip(isWheelOpen)', + ); + }); + + it('follows design:reduced-motion: a mouse at once, a touch screen the policy\u2019s cross-fade, no turn', () => { + const source = read('TouchDateField.tsx'); + const styles = source.slice( + source.indexOf('const styles = stylex.create('), + ); + // The policy's two keys (motion/reducedMotion.ts) come last in each + // property, so they win over the finger's value. A touch screen + // cross-fades; otherwise the change is at once. + expect(styles).not.toContain("'@media (prefers-reduced-motion: reduce)': "); + const fadeKey = + "'@media (prefers-reduced-motion: reduce) and (pointer: coarse)':"; + const instantKey = + "'@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))':"; + // Five fades plus the layer beneath take the policy's duration; the + // chevron does not turn. + expect( + styles.match( + /'@media \(prefers-reduced-motion: reduce\) and \(pointer: coarse\)':\s*reducedMotionFadeConsts\.duration/g, + ), + ).toHaveLength(5); + expect(styles.split(instantKey).length - 1).toBe(6); + expect(styles.split(fadeKey).length - 1).toBeGreaterThanOrEqual(6); + const chevron = declarations(source, 'titleChevron').join('\n'); + expect(chevron).toContain( + "'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s'", + ); + expect(chevron).toContain('RM_DIP_HALF'); + expect(source).toContain( + 'const chevronDipRef = useChevronDip(isWheelOpen)', + ); }); it('keeps the virtual keyboard down on the touch field', () => { diff --git a/packages/core/src/DateInput/MonthPane.tsx b/packages/core/src/DateInput/MonthPane.tsx index 9bf53d7c0b0ad..80afbb271c8f0 100644 --- a/packages/core/src/DateInput/MonthPane.tsx +++ b/packages/core/src/DateInput/MonthPane.tsx @@ -105,6 +105,13 @@ const styles = stylex.create({ borderStyle: 'solid', borderColor: 'transparent', backgroundColor: 'transparent', + }, + /** + * The mouse calendar's selection fades in. Under a finger a tapped day takes + * its style in the frame of the lift, as UIDatePicker's does + * (datePicker.calendar.selection.tapAnimates). + */ + puckFade: { transitionProperty: 'background-color, color, border-color', transitionDuration: durationVars['--duration-fast'], }, @@ -200,6 +207,8 @@ export interface MonthPaneProps { hasTabStop: boolean; /** The surface's own pane style: the scroller's snap or the pager's layer. */ paneStyle?: stylex.StyleXStyles; + /** Whether a day's selection fades in, as it does with a mouse. */ + hasSelectionFade?: boolean; } /** @@ -224,6 +233,7 @@ export const MonthPane = memo(function MonthPane({ onDayFocus, hasTabStop, paneStyle, + hasSelectionFade = false, }: MonthPaneProps) { const locale = useLocale(); const {calendar} = monthModel; @@ -319,6 +329,7 @@ export const MonthPane = memo(function MonthPane({ ))}
diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 89b173aa9b585..46270397a24d0 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -62,6 +62,7 @@ import {flushSync} from 'react-dom'; import {moveFocusLeftBehind, noteFocusWithin} from '../utils/focusReturn'; import * as stylex from '@stylexjs/stylex'; import {inputDisabledFocusStyles} from '../Field/inputDisabledFocus'; +import type {StyleXStyles} from '@stylexjs/stylex'; import {BottomSheet} from '../BottomSheet'; import {Button} from '../Button'; import {useCalendarConstraints} from '../Calendar'; @@ -125,7 +126,8 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {normalizeDayOfWeek} from '../utils/dateTypes'; import {getWeekdayHeaders} from '../Calendar/getWeekdayHeaderNames'; -import {MonthPager} from './datePickerTouch'; +import {MonthPager, useChevronDip, useWheelsWarmUp} from './datePickerTouch'; +import {useFadeInert} from './useFadeInert'; import {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; import {useTouchPresentation} from '../utils/touchPresentation'; import {MonthYearWheels} from './MonthYearWheels'; @@ -147,6 +149,8 @@ import english from '../i18n/generated-english/DateInput/TouchDateField.generate const UNKNOWN_TODAY_STAND_IN = plainDateFromISO('1970-01-01'); registerEnglishFallback(english); +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {monthYearToggleMotion} from './monthYearToggle.stylex'; /** * The comfortable minimum tap target on both iOS and Android, honoured by @@ -172,6 +176,15 @@ const TOUCH_TARGET = dateInputTouchSizes.daySize; * and not half of one. */ const SWAP_DURATION = durationVars['--duration-fast']; +/** + * UIDatePicker's month/year toggle under a finger: its fades and the chevron's + * quarter turn, 0.25 s on ease-in-ease-out (monthYearToggle.stylex.ts). + */ +const TOGGLE_DURATION = monthYearToggleMotion.duration; +const TOGGLE_CURVE = monthYearToggleMotion.curve; +const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; +/** Under reduced motion the chevron's turn applies at its dip's middle. */ +const RM_DIP_HALF = `calc(${reducedMotionFadeConsts.duration} / 2)`; const sizeStyles = stylex.create({ sm: { @@ -279,11 +292,24 @@ const styles = stylex.create({ // calendar the layer above cannot cover. They fade on its timing instead, // both directions, so the change reads as one motion rather than chrome // blinking out a beat before the grid is covered. + // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's + // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on + // a touch screen and immediate with a mouse. transitionProperty: 'opacity, visibility', - transitionDuration: SWAP_DURATION, - transitionTimingFunction: 'linear', - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Last, so they win over the finger's value. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, /** @@ -336,11 +362,24 @@ const styles = stylex.create({ headerReset: { display: 'flex', alignItems: 'center', + // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's + // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on + // a touch screen and immediate with a mouse. transitionProperty: 'opacity, visibility', - transitionDuration: SWAP_DURATION, - transitionTimingFunction: 'linear', - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Last, so they win over the finger's value. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, headerResetHidden: { @@ -397,16 +436,47 @@ const styles = stylex.create({ // The one part of the swap that keeps `--ease-standard`, because it is // the one part that travels: a rotation has a distance to cover, and // fast-out-slow-in is what that curve is for. Same duration as the - // surface it announces, so the two land together. + // surface it announces, so the two land together. Under a finger it is + // UIDatePicker's chevron. It points along the reading direction for the + // calendar and turns down on the toggle's own clock. + transform: { + default: 'none', + '@media (pointer: coarse)': { + default: `rotate(-${CHEVRON_TURN})`, + // Mirrored under RTL, so it points along the reading direction; the + // same function list in both states keeps the turn a turn. + ':is([dir="rtl"] *)': `scaleX(-1) rotate(-${CHEVRON_TURN})`, + }, + }, transitionProperty: 'transform', - transitionDuration: SWAP_DURATION, - transitionTimingFunction: easeVars['--ease-standard'], - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Under Reduced Motion the chevron does not turn. On a touch screen the + // turn applies at the middle of its opacity dip (useChevronDip), and + // with a mouse at once. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionDelay: { + default: '0s', + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + RM_DIP_HALF, + }, + transitionTimingFunction: { + default: easeVars['--ease-standard'], + '@media (pointer: coarse)': TOGGLE_CURVE, }, }, titleChevronOpen: { - transform: 'rotate(180deg)', + transform: { + default: 'rotate(180deg)', + '@media (pointer: coarse)': { + default: 'rotate(0deg)', + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)', + }, + }, }, weekdays: { display: 'grid', @@ -415,11 +485,24 @@ const styles = stylex.create({ alignItems: 'center', // Same as the arrows: above the plate's reach, so it fades on the layer's // timing rather than clearing on its own. + // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's + // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on + // a touch screen and immediate with a mouse. transitionProperty: 'opacity, visibility', - transitionDuration: SWAP_DURATION, - transitionTimingFunction: 'linear', - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Last, so they win over the finger's value. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, weekdaysHidden: { @@ -439,6 +522,20 @@ const styles = stylex.create({ blockSize: dateInputTouchGeometry.paneBlockSize, position: 'relative', }, + /** + * While the month/year toggle cross-fades, a shield over both panels takes + * touches, as a UIKit view animation ignores them. `inert` swaps only at + * the fade's end (useFadeInert). A pseudo-element on the body, so showing + * it restyles nothing inside the panels. + */ + bodyFading: { + '::after': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: 3, + }, + }, /** * The two panels share one grid cell, and the wheels are the one on top. */ @@ -467,14 +564,21 @@ const styles = stylex.create({ */ panelBeneath: { transitionProperty: 'visibility', - transitionDuration: SWAP_DURATION, - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Last, so they win over the finger's value. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', }, }, + // No `pointerEvents` here: it is inherited, so flipping it in the toggle's + // commit would restyle the calendar before the fade's first frame. `inert` + // and `visibility` keep it untouchable. panelBeneathHidden: { visibility: 'hidden', - pointerEvents: 'none', }, /** * The month and year, as one layer that fades in and out on top. @@ -517,11 +621,24 @@ const styles = stylex.create({ panelOverlay: { backgroundColor: colorVars['--color-background-surface'], isolation: 'isolate', + // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's + // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on + // a touch screen and immediate with a mouse. transitionProperty: 'opacity, visibility', - transitionDuration: SWAP_DURATION, - transitionTimingFunction: 'linear', - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: '0.01s', + transitionDuration: { + default: SWAP_DURATION, + '@media (pointer: coarse)': TOGGLE_DURATION, + // Last, so they win over the finger's value. + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, panelOverlayHidden: { @@ -529,6 +646,12 @@ const styles = stylex.create({ opacity: 0, pointerEvents: 'none', }, + // The hidden wheels under a finger once the sheet has settled: laid out, at + // opacity 0, so the toggle's commit only changes their opacity + // (useWheelsWarmUp). Still inert and hidden from assistive technology. + panelOverlayWarm: { + visibility: 'visible', + }, footer: { paddingBlockStart: spacingVars['--spacing-2'], // Same reason as the header: the content box owns the inline inset, so @@ -690,8 +813,29 @@ export function TouchDateField({ isSheetOpen ? sheetBodyRef : inputRef, ); const [isWheelOpen, setIsWheelOpen] = useState(false); + // The panels swap `inert` when the toggle's fade ends (useFadeInert), since + // flipping it restyles a panel's subtree. `aria-hidden` follows the toggle + // at once, and a shield takes touches while the fade runs. + const calendarPanelRef = useRef(null); + const wheelsPanelRef = useRef(null); const {isTouch} = useTouchPresentation(); + const {isCalendarInert, isWheelsInert, isFading} = useFadeInert( + isWheelOpen, + calendarPanelRef, + wheelsPanelRef, + isTouch, + ); + // The header's actions and the footer's two leave with the toggle and + // arrive with the fade's end. They sit outside the panels' shield, so they + // go inert at once. Otherwise a tap on Done during the fade would fall + // through to Save. + const isCalendarActionsInert = isWheelOpen || isCalendarInert; + const isWheelsActionsInert = !isWheelOpen || isWheelsInert; const MonthGrid = isTouch ? MonthPager : MonthScroller; + // Once the sheet settles, the hidden wheels are laid out at opacity 0, so + // the toggle itself only changes opacity. + const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); + const chevronDipRef = useChevronDip(isWheelOpen); const scrollerHandleRef = useRef(null); const previousArrowRef = useRef(null); const nextArrowRef = useRef(null); @@ -1023,15 +1167,20 @@ export function TouchDateField({ * lifts, so each lap committed the next month along and the value climbed * on its own. Ignoring the echo removes the cycle instead of damping it. */ + // Read through a ref, so the toggle leaves this callback unchanged. The + // memoized month grid that takes it does not re-render when the wheels open + // or close. + const isWheelOpenRef = useRef(isWheelOpen); + isWheelOpenRef.current = isWheelOpen; const handleVisibleMonthChange = useCallback( (next: number) => { - if (isWheelOpen) { + if (isWheelOpenRef.current) { return; } // A swipe to the range end hides an arrow, as a press does. moveMonth(next, () => setMonthIndex(next)); }, - [isWheelOpen, moveMonth], + [moveMonth], ); /** @@ -1087,6 +1236,122 @@ export function TouchDateField({ [openSheet], ); + // The toggle re-renders only what it changes: the title's chevron, the + // panels' classes and `aria-hidden`, and the wheels, which go live with it. + // A live hidden wheel would hold the compositor to the main thread during + // the fade. The buttons keep their identity, so React skips them. + const closeSheet = useCallback(() => setIsSheetOpen(false), []); + const closeWheels = useCallback(() => setIsWheelOpen(false), []); + const monthArrowButtons = useMemo( + () => ( + <> + stepMonthFromArrow(-1)} + label={t('@astryx.calendar.previousMonth')} + icon={ + + + + } + /> + stepMonthFromArrow(1)} + label={t('@astryx.calendar.nextMonth')} + icon={ + + + + } + /> + + ), + [canStepBack, canStepForward, stepMonthFromArrow, t], + ); + const resetButton = useMemo( + () => ( + {/* Both arrows at the trailing corner, as a pair — and only while the @@ -1139,45 +1410,12 @@ export function TouchDateField({ // `inert` as well as the hidden styling: the fade keeps them // `visible` until it finishes, and a control that is on its way out // should not answer a click or a Tab in the meantime. - inert={isWheelOpen ? true : undefined} + inert={isCalendarActionsInert ? true : undefined} {...stylex.props( styles.monthArrows, isWheelOpen && styles.monthArrowsHidden, )}> - stepMonthFromArrow(-1)} - label={t('@astryx.calendar.previousMonth')} - icon={ - - - - } - /> - stepMonthFromArrow(1)} - label={t('@astryx.calendar.nextMonth')} - icon={ - - - - } - /> + {monthArrowButtons} {/* Reset, past the arrows, and gone with them on the wheels: the wheels choose a month, and there is no date there to put back. @@ -1185,20 +1423,12 @@ export function TouchDateField({ keeps its size, so the header cannot change height mid-swap. */} -
@@ -1218,18 +1448,23 @@ export function TouchDateField({ ))} -
+
- + {monthYearWheels}
@@ -1291,40 +1520,22 @@ export function TouchDateField({ belongs to a surface: Save finishes on the calendar, Done finishes the detour through the wheels. */}
-
-
diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts new file mode 100644 index 0000000000000..dbc698029d3f3 --- /dev/null +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -0,0 +1,950 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MonthYearToggle.interaction.browser.spec.ts + * @input The built "Bottom Sheet Calendar" DateInput story, real touch input + * and the interaction harness + * @output Interaction proofs that the calendar's month/year toggle runs on + * UIDatePicker's clock under a finger, in Chromium and WebKit: the fades, + * the chevron, a turnaround, a blocked main thread, Reduced Motion and RTL + * @position Interaction tests of TouchDateField.tsx, TouchDateTimeField.tsx and + * MonthPager.tsx (`*.interaction.browser.spec.ts`) + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + animationStoryboard, + blockMainThread, + busyWindowProof, + freezeTimers, + installTimerGate, + markElement, + openStory, + releaseAnimations, + startScreencast, + thawTimers, + timeline, + touchDriver, + waitForAnimation, + waitForRest, + watchAnimations, + withStorybook, + writeEvidence, + type ActiveAnimation, + type CastFrame, + type TrackedFrame, +} from '@astryxdesign/interaction-harness'; +import {SHEET_SETTLE_MS} from '../useWheelsWarmUp'; +import {datePicker} from '../../motion/uikit/datePicker'; +import {timing} from '../../motion/uikit/timing'; + +const storybook = withStorybook(test); + +const TOGGLE_MS = datePicker.calendar.monthYearToggle.fadeMs; + +/** CSS's named cubic-bézier easings, which a browser may report by name. */ +const NAMED_EASINGS: Record> = { + ease: [0.25, 0.1, 0.25, 1], + 'ease-in': [0.42, 0, 1, 1], + 'ease-out': [0, 0, 0.58, 1], + 'ease-in-out': [0.42, 0, 0.58, 1], + linear: [0, 0, 1, 1], +}; + +/** + * A reported easing's cubic-bézier control points: Chromium reports + * `cubic-bezier(0.42, 0, 0.58, 1)` where WebKit names it `ease-in-out`. + */ +function bezierOf(easing: string): ReadonlyArray { + const named = NAMED_EASINGS[easing.trim()]; + if (named != null) { + return named; + } + const match = /^cubic-bezier\(([^)]+)\)$/.exec(easing.trim()); + if (match == null) { + throw new Error(`not a cubic-bézier easing: ${easing}`); + } + return match[1].split(',').map(Number); +} + +/** + * The toggle's run of `property` as the page plays it: UIKit's 0.25 s + * ease-in-ease-out from one end value to the other. It is read from the + * watched transition itself, whose duration, easing and end values fix every + * frame. A hold taken after the tap returns can miss the fade on a loaded + * runner. + */ +function expectToggleRun( + runs: ReadonlyArray, + property: string, + [from, to]: readonly [string, string], +) { + const run = runs.find(candidate => candidate.name === property); + if (run == null) { + throw new Error(`no ${property} run among ${JSON.stringify(runs)}`); + } + expect(run.durationMs).toBe(TOGGLE_MS); + const points = bezierOf(run.easing); + timing.curves.move.forEach((point, index) => + expect(points[index], run.easing).toBeCloseTo(point, 3), + ); + const frames = run.keyframes; + expect(String(frames[0][property])).toBe(from); + expect(String(frames[frames.length - 1][property])).toBe(to); +} + +const STORY = 'core-dateinput--bottom-sheet-calendar'; +const DTI_STORY = 'core-datetimeinput--bottom-sheet-calendar'; + +async function openCalendar( + page: Page, + {rtl = false, story = STORY}: {rtl?: boolean; story?: string} = {}, +) { + if (rtl) { + // The Storybook toolbar's direction global, as the URL sets it. + await page.goto( + `${storybook()}/iframe.html?id=${STORY}&viewMode=story&globals=direction:rtl`, + ); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => document.fonts.ready); + } else { + await openStory(page, storybook(), story); + } + const driver = await touchDriver(page); + const opener = page.getByRole('button', {name: /calendar/i}).first(); + const box = await opener.boundingBox(); + if (box == null) { + throw new Error('no opener'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const sheet = page + .getByRole('dialog') + .locator('.astryx-bottom-sheet') + .first(); + await waitForRest(sheet); + // Under a finger the hidden wheels warm up once the sheet has settled + // (useWheelsWarmUp): their `visibility` turns visible at opacity 0, with its + // own transition. Wait for its end, so a test's first tap meets a panel at + // rest. `blockMainThread` and `waitForAnimation` take any running animation + // on the panel, and on a slow runner the warm-up can still be running. + const wheels = page.locator('[data-panel="wheels"]').first(); + await expect + .poll(async () => wheels.evaluate(e => getComputedStyle(e).visibility), { + timeout: SHEET_SETTLE_MS + 2000, + }) + .toBe('visible'); + await waitForRest(wheels); + const title = page.locator('[data-title="month-year"]'); + const titleBox = await title.boundingBox(); + if (titleBox == null) { + throw new Error('no title'); + } + return { + driver, + sheet, + wheels, + titleAt: { + x: titleBox.x + titleBox.width / 2, + y: titleBox.y + titleBox.height / 2, + }, + }; +} + +/** Every running animation under `root`: its animated properties and duration. */ +async function runningAnimations(page: Page, selector: string) { + return page + .locator(selector) + .first() + .evaluate(root => + root.getAnimations({subtree: true}).map(animation => { + const effect = animation.effect as KeyframeEffect; + return { + properties: [ + ...new Set( + effect + .getKeyframes() + .flatMap(frame => + Object.keys(frame).filter( + key => + ![ + 'offset', + 'easing', + 'composite', + 'computedOffset', + ].includes(key), + ), + ), + ), + ], + duration: Number(effect.getTiming().duration), + }; + }), + ); +} + +test('the wheels fade in and the chevron turns on UIKit’s 0.25 s ease-in-ease-out', async ({ + page, +}) => { + const {driver, wheels, titleAt} = await openCalendar(page); + const chevron = page + .locator('[data-title="month-year"] .astryx-icon') + .first(); + const fade = await watchAnimations(wheels); + const turn = await watchAnimations(chevron); + await driver.tap(titleAt); + expectToggleRun(await fade.started(), 'opacity', ['0', '1']); + expectToggleRun(await turn.started(), 'transform', [ + 'rotate(-90deg)', + 'rotate(0deg)', + ]); +}); + +test('DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them', async ({ + page, +}) => { + await openStory(page, storybook(), DTI_STORY); + const driver = await touchDriver(page); + const tapCenter = async (selector: string) => { + const box = await page.locator(selector).first().boundingBox(); + if (box == null) { + throw new Error(`no ${selector}`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter('button[aria-label^="Open "]'); + const wheels = page.locator('[data-date-surface="wheels"]').first(); + await expect(wheels).toBeAttached(); + await waitForRest( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + ); + const chevron = page + .locator('button[aria-label$="Choose month and year"] .astryx-icon') + .first(); + const fade = await watchAnimations(wheels); + const turn = await watchAnimations(chevron); + await tapCenter('button[aria-label$="Choose month and year"]'); + expectToggleRun(await fade.started(), 'opacity', ['0', '1']); + expectToggleRun(await turn.started(), 'transform', [ + 'rotate(-90deg)', + 'rotate(0deg)', + ]); +}); + +test('a second tap mid-fade turns the fade around from where it is', async ({ + page, +}) => { + const {driver, wheels, titleAt} = await openCalendar(page); + // A test stylesheet stretches the toggle's fades and the chevron's turn to + // 2 s. So the second tap lands mid-fade every time, instead of racing a + // 250 ms fade. The turnaround does not depend on the length. + await page.addStyleTag({ + content: + '[data-panel], button[aria-label$="Choose month and year"] .astryx-icon { transition-duration: 2000ms !important; }', + }); + // Every frame's opacity, with the page's clock, from before the first tap + // until the wheels rest: the turnaround is read from the fade itself. A log + // started after the wait below could begin once the turnaround had begun, + // reading its first value as the peak. + await wheels.evaluate(element => { + const state = {log: [] as [number, number][], stop: false}; + (window as unknown as {fade: typeof state}).fade = state; + const t0 = performance.now(); + const tick = () => { + state.log.push([ + performance.now() - t0, + Number(getComputedStyle(element).opacity), + ]); + if (!state.stop) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + await driver.tap(titleAt); + await wheels.evaluate( + async element => + new Promise(resolve => { + const check = () => { + if (Number(getComputedStyle(element).opacity) > 0.3) { + resolve(); + } else { + requestAnimationFrame(check); + } + }; + check(); + }), + ); + await driver.tap(titleAt); + await waitForRest(wheels); + const fade = await page.evaluate(() => { + const state = ( + window as unknown as {fade: {log: [number, number][]; stop: boolean}} + ).fade; + state.stop = true; + return state.log; + }); + const values = fade.map(([, opacity]) => opacity); + const peak = Math.max(...values); + const peakAt = values.indexOf(peak); + // The first fade never finished: the second tap caught it part way in. + expect(peak).toBeGreaterThan(0.3); + expect(peak).toBeLessThan(0.95); + // From there it only falls back to 0: never up to 1, never a jump. The + // fall is bounded per millisecond, so a dropped frame is not a jump. + for (let i = peakAt + 1; i < fade.length; i += 1) { + const [time, opacity] = fade[i]; + const [lastTime, last] = fade[i - 1]; + expect(opacity).toBeLessThanOrEqual(last + 1e-6); + expect((last - opacity) / Math.max(1, time - lastTime)).toBeLessThan(0.004); + } + expect(fade.at(-1)?.[1]).toBe(0); +}); + +/** Resolves once `locator`'s element shows more than `opacity`, page-side. */ +async function waitForOpacityAbove(locator: Locator, opacity: number) { + await locator.evaluate( + async (element, above) => + new Promise(resolve => { + const check = () => { + if (Number(getComputedStyle(element).opacity) > above) { + resolve(); + } else { + requestAnimationFrame(check); + } + }; + check(); + }), + opacity, + ); +} + +/** Every transition in the open sheet, stretched so a tap lands mid-fade. */ +const STRETCH_SHEET_FADES = + 'dialog[open] * { transition-duration: 2000ms !important; }'; + +test('a tap on Done during the opening fade does not reach Save beneath it', async ({ + page, +}) => { + const {driver, wheels, titleAt} = await openCalendar(page); + const field = page.getByRole('combobox').first(); + const value = await field.inputValue(); + // Counts the clicks that reach Save, beneath Done in the footer. + await page + .locator('dialog[open] button', {hasText: /^Save$/}) + .evaluate(button => { + const counter = window as unknown as {saveClicks: number}; + counter.saveClicks = 0; + button.addEventListener('click', () => { + counter.saveClicks += 1; + }); + }); + await page.addStyleTag({content: STRETCH_SHEET_FADES}); + await driver.tap(titleAt); + await waitForOpacityAbove(wheels, 0.3); + const done = await page + .locator('dialog[open] button', {hasText: /^Done$/}) + .boundingBox(); + if (done == null) { + throw new Error('no Done'); + } + await driver.tap({x: done.x + done.width / 2, y: done.y + done.height / 2}); + await waitForRest(wheels); + // Nothing reached Save: the sheet is still open, on the wheels, and + // nothing was saved. + expect( + await page.evaluate( + () => (window as unknown as {saveClicks: number}).saveClicks, + ), + ).toBe(0); + await expect(page.locator('dialog[open]')).toHaveCount(1); + expect(await wheels.evaluate(e => getComputedStyle(e).opacity)).toBe('1'); + expect(await field.inputValue()).toBe(value); +}); + +test('DateTimeInput: a tap on the month arrows during the opening fade changes nothing', async ({ + page, +}) => { + await openStory(page, storybook(), DTI_STORY); + const driver = await touchDriver(page); + const tapCenter = async (selector: string) => { + const box = await page.locator(selector).first().boundingBox(); + if (box == null) { + throw new Error(`no ${selector}`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter('button[aria-label^="Open "]'); + await waitForRest( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + ); + // Counts the clicks that reach the next-month arrow. + await page + .locator('button[aria-label="Next month"]') + .first() + .evaluate(button => { + const counter = window as unknown as {arrowClicks: number}; + counter.arrowClicks = 0; + button.addEventListener('click', () => { + counter.arrowClicks += 1; + }); + }); + await page.addStyleTag({content: STRETCH_SHEET_FADES}); + await tapCenter('button[aria-label$="Choose month and year"]'); + await waitForOpacityAbove( + page.locator('[data-date-surface="wheels"]').first(), + 0.3, + ); + await tapCenter('button[aria-label="Next month"]'); + await waitForRest(page.locator('[data-date-surface="wheels"]').first()); + expect( + await page.evaluate( + () => (window as unknown as {arrowClicks: number}).arrowClicks, + ), + ).toBe(0); +}); + +/** + * The chip's mean green channel in every frame: magenta over whatever is + * beneath, so it moves with the panel's opacity. + */ +async function chipShades( + page: Page, + frames: ReadonlyArray, + rect: {x: number; y: number; width: number; height: number}, +): Promise<{t: number; green: number}[]> { + const scratch = await page.context().newPage(); + try { + const out: {t: number; green: number}[] = []; + for (let i = 0; i < frames.length; i += 12) { + const batch = frames.slice(i, i + 12); + const greens = await scratch.evaluate( + async ({items, box}) => { + const results: number[] = []; + for (const item of items) { + const blob = await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(); + const bitmap = await createImageBitmap(blob); + const scale = bitmap.width / item.viewportWidth; + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + // The chip's middle, clear of its anti-aliased edge. + const x = Math.round((box.x + box.width / 4) * scale); + const y = Math.round((box.y + box.height / 4) * scale); + const w = Math.max(1, Math.round((box.width / 2) * scale)); + const h = Math.max(1, Math.round((box.height / 2) * scale)); + const {data} = context.getImageData(x, y, w, h); + let sum = 0; + for (let o = 1; o < data.length; o += 4) { + sum += data[o]; + } + results.push(sum / (data.length / 4)); + } + return results; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + })), + box: rect, + }, + ); + greens.forEach((green, k) => out.push({t: batch[k].t, green})); + } + return out; + } finally { + await scratch.close(); + } +} + +test('the wheels keep fading in every frame through a blocked main thread', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName === 'webkit', + "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so a 0.25 s fade cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", + ); + const {driver, wheels, titleAt} = await openCalendar(page); + // A magenta chip on the wheels' panel: its colour on screen is the fade. + await markElement(wheels, {inset: 8, size: 24}); + const chip = page.locator('[data-harness-marker-chip]').first(); + const rect = await chip.boundingBox(); + if (rect == null) { + throw new Error('no chip'); + } + const cast = await startScreencast(page); + await page.waitForTimeout(300); + await driver.tap(titleAt); + // The page blocks itself once the fade has run 32 ms: started, and on + // screen. This guards the compositor path once a fade is on screen, which + // a CSS fade passes too; the next case is what the animator changes. + const busy = await blockMainThread(page, 600, { + during: wheels, + minProgressMs: 32, + }); + await page.waitForTimeout(600); + const shades = await chipShades(page, await cast.stop(), rect); + const before = shades[0]?.green; + const end = shades.at(-1)?.green; + if (before == null || end == null || before - end < 50) { + throw new Error('the chip did not show the fade'); + } + // Opacity in thousandths, as a position the busy proof can judge. + const tracked: TrackedFrame[] = shades.map(s => ({ + t: s.t, + box: { + x: (1000 * (before - s.green)) / (before - end), + y: 0, + width: 0, + height: 0, + }, + pixels: 1, + })); + const started = busy.from - 32; + const spanned = {...busy, to: Math.min(busy.to, started + TOGGLE_MS)}; + expect(spanned.to - spanned.from).toBeGreaterThan(150); + const proof = busyWindowProof(tracked, spanned, { + axis: 'x', + settledWithin: 10, + }); + await writeEvidence( + 'busy-main-thread/month-year-toggle-fade', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + busy, + proof, + frames: tracked.map(f => ({ + t: +(f.t - started).toFixed(1), + opacity: +((f.box?.x ?? 0) / 1000).toFixed(3), + })), + }, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'busy-main-thread', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); +}); + +test('Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning', async ({ + page, +}) => { + // On a touch screen: opacity only, plus the discrete `visibility` that + // follows it, for 200 ms or less from the policy's keys. The chevron's turn + // applies at the middle of its dip. + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, wheels, titleAt} = await openCalendar(page); + await driver.tap(titleAt); + const panel = await runningAnimations(page, '[data-panel="wheels"]'); + expect(panel.map(animation => animation.properties).flat()).toContain( + 'opacity', + ); + for (const animation of panel) { + for (const property of animation.properties) { + expect(['opacity', 'visibility']).toContain(property); + } + expect(animation.duration).toBeLessThanOrEqual(200); + } + // The chevron dips; its turn is a zero-length transition at the dip's + // middle, an instant change rather than travel. + const chevron = ( + await runningAnimations(page, '[data-title="month-year"] .astryx-icon') + ).filter(animation => animation.duration > 0); + expect(chevron.length).toBeGreaterThan(0); + for (const animation of chevron) { + expect(animation.properties).toEqual(['opacity']); + expect(animation.duration).toBeLessThanOrEqual(200); + } + const turn = await page + .locator('[data-title="month-year"] .astryx-icon') + .first() + .evaluate(element => { + const style = getComputedStyle(element); + return {duration: style.transitionDuration, delay: style.transitionDelay}; + }); + expect(turn).toEqual({duration: '0s', delay: '0.1s'}); + await waitForRest(wheels); +}); + +test('in RTL the chevron points left, and turns to point down', async ({ + page, +}) => { + const {driver, wheels, titleAt} = await openCalendar(page, {rtl: true}); + // Where the chevron's glyph (drawn pointing down) points on screen: its + // transform (the turn, mirrored under RTL) applied to +y. + const pointing = async () => + page.evaluate(() => { + const icon = document.querySelector( + '[data-title="month-year"] .astryx-icon', + ); + if (icon == null) { + throw new Error('no chevron'); + } + const value = getComputedStyle(icon).transform; + const matrix = new DOMMatrix(value === 'none' ? undefined : value); + const p = matrix.transformPoint(new DOMPoint(0, 1)); + const q = matrix.transformPoint(new DOMPoint(0, 0)); + return {x: +(p.x - q.x).toFixed(3), y: +(p.y - q.y).toFixed(3)}; + }); + await expect(page.locator('[dir="rtl"]').first()).toBeAttached(); + expect(await pointing()).toEqual({x: -1, y: 0}); + await driver.tap(titleAt); + await waitForRest(wheels); + await page.waitForTimeout(TOGGLE_MS + 100); + expect(await pointing()).toEqual({x: 0, y: 1}); +}); + +test('a tapped day takes its selection at once', async ({page}) => { + const {driver} = await openCalendar(page); + const day = page.getByRole('button', {name: /October 20, 2026/}); + const box = await day.boundingBox(); + if (box == null) { + throw new Error('no day'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + // No transition on the day: nothing is animating once the tap is handled. + const running = await day.evaluate( + element => element.getAnimations({subtree: true}).length, + ); + expect(running).toBe(0); + await expect(page.getByRole('gridcell', {selected: true})).toContainText( + '20', + ); +}); + +/** The computed state of the hidden wheels panel and its fade. */ +async function wheelsState(page: Page, selector: string) { + return page + .locator(selector) + .first() + .evaluate(element => { + const style = getComputedStyle(element); + return { + visibility: style.visibility, + opacity: style.opacity, + inert: (element as HTMLElement).inert, + ariaHidden: element.getAttribute('aria-hidden'), + transitionProperty: style.transitionProperty, + transitionDuration: style.transitionDuration, + transitionTimingFunction: style.transitionTimingFunction, + inlineOpacity: (element as HTMLElement).style.opacity, + }; + }); +} + +for (const [name, story, selector] of [ + ['DateInput', STORY, '[data-panel="wheels"]'], + ['DateTimeInput', DTI_STORY, '[data-date-surface="wheels"]'], +] as const) { + test(`${name}: once the sheet settles, the hidden wheels are laid out at opacity 0, still inert`, async ({ + page, + }) => { + // Their cylinders' layers then exist before the title is tapped. So the + // toggle's commit only changes opacity, and Mobile Safari builds nothing in + // the fade's first frames. + if (story === STORY) { + await openCalendar(page); + } else { + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + const opener = page.getByRole('button', {name: /^Open /}).first(); + const box = await opener.boundingBox(); + if (box == null) { + throw new Error('no opener'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + } + await expect(page.locator(selector)).toBeAttached(); + await expect + .poll(async () => (await wheelsState(page, selector)).visibility, { + timeout: SHEET_SETTLE_MS + 2000, + }) + .toBe('visible'); + const state = await wheelsState(page, selector); + expect(state.opacity).toBe('0'); + expect(state.inert).toBe(true); + expect(state.ariaHidden).toBe('true'); + }); +} + +type CommitLog = { + frame: number; + /** The document timeline's time, ms. */ + time: number; + panel: string; + attribute: string | null; + present: boolean; + opacity: number; + visibility: string; + wheelsOpacity: number; + pointerEvents: string; +}[]; + +for (const [name, story, titleSelector, panelSelector, panelKey] of [ + ['DateInput', STORY, '[data-title="month-year"]', '[data-panel]', 'panel'], + [ + 'DateTimeInput', + DTI_STORY, + 'button[aria-label$="Choose month and year"]', + '[data-date-surface]', + 'dateSurface', + ], +] as const) { + test(`${name}: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends`, async ({ + page, + }) => { + // `inert` and `pointer-events` are inherited: flipping either restyles a + // panel's whole subtree. So the toggle's commit changes only the panels' + // classes and `aria-hidden`. A shield takes touches during the fade, and + // the panels swap `inert` at its end, when nothing moves. + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + const tapCenter = async (selector: string) => { + const box = await page.locator(selector).first().boundingBox(); + if (box == null) { + throw new Error(`no ${selector}`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter('button[aria-label^="Open "]'); + const wheels = page + .locator(`${panelSelector.slice(0, -1)}="wheels"]`) + .first(); + await expect + .poll(async () => wheels.evaluate(e => getComputedStyle(e).visibility), { + timeout: SHEET_SETTLE_MS + 2000, + }) + .toBe('visible'); + await page.evaluate( + ({selector, key}) => { + const state = { + frames: 0, + log: [] as CommitLog, + shielded: null as boolean | null, + }; + (window as unknown as {toggleCommit: typeof state}).toggleCommit = + state; + const tick = () => { + state.frames += 1; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + const panels = [...document.querySelectorAll(selector)]; + const observer = new MutationObserver(records => { + if (state.shielded == null) { + // One frame into the fade: a touch on the panels' middle meets + // the shield, not a control inside either panel. + state.shielded = false; + requestAnimationFrame(() => { + const box = panels[0].getBoundingClientRect(); + const hit = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + state.shielded = + hit != null && !panels.some(panel => panel.contains(hit)); + }); + } + for (const record of records) { + const target = record.target as HTMLElement; + const style = getComputedStyle(target); + const wheelsPanel = panels.find( + panel => panel.dataset[key] === 'wheels', + ); + state.log.push({ + frame: state.frames, + time: Number(document.timeline.currentTime), + panel: target.dataset[key] ?? '', + attribute: record.attributeName, + present: + record.attributeName != null && + target.hasAttribute(record.attributeName), + opacity: Number(style.opacity), + visibility: style.visibility, + wheelsOpacity: + wheelsPanel == null + ? NaN + : Number(getComputedStyle(wheelsPanel).opacity), + pointerEvents: style.pointerEvents, + }); + } + }); + for (const panel of panels) { + observer.observe(panel, { + attributes: true, + attributeFilter: ['class', 'inert', 'aria-hidden'], + }); + } + }, + {selector: panelSelector, key: panelKey}, + ); + await tapCenter(titleSelector); + await waitForRest(wheels); + await page.waitForTimeout(150); + const {log, shielded} = await page.evaluate( + () => + ( + window as unknown as { + toggleCommit: {log: CommitLog; shielded: boolean | null}; + } + ).toggleCommit, + ); + expect(shielded).toBe(true); + const commitFrame = log.find(entry => entry.attribute === 'class')?.frame; + expect(commitFrame, JSON.stringify(log)).toBeDefined(); + for (const change of log.filter( + entry => entry.attribute === 'class' && entry.frame === commitFrame, + )) { + expect(change.pointerEvents).toBe('auto'); + } + // The outgoing calendar leaves assistive technology with the toggle. + const hidden = log.find( + entry => + entry.panel === 'calendar' && + entry.attribute === 'aria-hidden' && + entry.present, + ); + expect(hidden?.frame, JSON.stringify(log)).toBe(commitFrame); + // The incoming wheels go live when the fade ends, with the swap. + const live = log.find( + entry => + entry.panel === 'wheels' && + entry.attribute === 'inert' && + !entry.present, + ); + expect(live, JSON.stringify(log)).toBeDefined(); + // Both waits are counted on the document timeline, not in frames: a + // loaded runner presents fewer, longer frames. The swap still waits for the + // fade's end, with the wheels fully faded in. + const commitTime = + log.find(entry => entry.attribute === 'class')?.time ?? Number.NaN; + expect( + (live?.time ?? Number.NaN) - commitTime, + JSON.stringify(log), + ).toBeGreaterThanOrEqual(TOGGLE_MS); + expect(live?.wheelsOpacity, JSON.stringify(log)).toBe(1); + // The outgoing calendar turns inert once the cross-fade has ended: faded + // to 0, hidden, or covered by the wheels at opacity 1. Never part way. + const inert = log.find( + entry => + entry.panel === 'calendar' && + entry.attribute === 'inert' && + entry.present, + ); + expect(inert, JSON.stringify(log)).toBeDefined(); + expect( + inert?.opacity === 0 || + inert?.visibility === 'hidden' || + inert?.wheelsOpacity === 1, + JSON.stringify(inert), + ).toBe(true); + expect( + (inert?.time ?? Number.NaN) - commitTime, + JSON.stringify(log), + ).toBeGreaterThanOrEqual(TOGGLE_MS); + }); +} + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('under Reduced Motion the toggle changes at once', async ({page}) => { + // design:reduced-motion DR3: no animated property with a mouse. + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); + await expect(page.locator('[data-panel="wheels"]').first()).toBeAttached(); + const state = await wheelsState(page, '[data-panel="wheels"]'); + expect(state.transitionDuration).toBe('0s'); + const turn = await page + .locator('[data-title="month-year"] .astryx-icon') + .first() + .evaluate(element => getComputedStyle(element).transitionDuration); + expect(turn).toBe('0s'); + }); + + test('the swap keeps its values: a linear opacity fade on --duration-fast', async ({ + page, + }) => { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); + const wheels = page.locator('[data-panel="wheels"]').first(); + await expect(wheels).toBeAttached(); + const state = await wheelsState(page, '[data-panel="wheels"]'); + const fast = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--duration-fast') + .trim(), + ); + expect(state.transitionProperty).toBe('opacity, visibility'); + expect(state.transitionTimingFunction).toBe('linear'); + expect(state.visibility).toBe('hidden'); + expect(fast).not.toBe(''); + const durationMs = (value: string) => + value.endsWith('ms') ? parseFloat(value) : parseFloat(value) * 1000; + expect(durationMs(state.transitionDuration)).toBeCloseTo( + durationMs(fast), + 3, + ); + }); +}); + +test( + 'storyboard: the month/year toggle', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, sheet, wheels, titleAt} = await openCalendar(page); + await installTimerGate(page); + await driver.tap(titleAt); + await waitForAnimation(wheels); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'month-year-toggle', + clip: sheet, + times: timeline(TOGGLE_MS, 1000 / 30), + columns: 5, + cellScale: 0.35, + measure: async () => ({ + opacity: +( + await wheels.evaluate(el => Number(getComputedStyle(el).opacity)) + ).toFixed(3), + }), + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + }, +); diff --git a/packages/core/src/DateInput/datePickerTouch.ts b/packages/core/src/DateInput/datePickerTouch.ts index e12c800f863ae..777139ccd7d0e 100644 --- a/packages/core/src/DateInput/datePickerTouch.ts +++ b/packages/core/src/DateInput/datePickerTouch.ts @@ -4,7 +4,7 @@ * @file datePickerTouch.ts * @input The date and time pickers' touch-only pieces * @output Re-exports what the date and time sheets use only under a finger: - * TimeWheels and MonthPager + * TimeWheels, MonthPager, useWheelsWarmUp and useChevronDip * @position The pickers' touch layer. The sheets import touch behaviour from * here only, so a desktop build can replace this one module. * @@ -15,3 +15,5 @@ export {TimeWheels} from '../TimeInput/TimeWheels'; export type {TimeWheelsProps} from '../TimeInput/TimeWheels'; export {MonthPager} from './MonthPager'; +export {useChevronDip} from './useChevronDip'; +export {useWheelsWarmUp} from './useWheelsWarmUp'; diff --git a/packages/core/src/DateInput/monthYearToggle.stylex.ts b/packages/core/src/DateInput/monthYearToggle.stylex.ts new file mode 100644 index 0000000000000..8af071512d785 --- /dev/null +++ b/packages/core/src/DateInput/monthYearToggle.stylex.ts @@ -0,0 +1,32 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file monthYearToggle.stylex.ts + * @input StyleX constants + * @output Exports monthYearToggleMotion, UIDatePicker's month/year toggle + * timing for the calendar under a finger + * @position Internal constants consumed by TouchDateField.tsx and + * TouchDateTimeField.tsx; not a package export (the released DateInput + * tokens live in tokens.stylex.ts) + * + * SYNC: When modified, update: + * - /packages/core/src/motion/uikit/datePicker.ts (the measured values) + * - /packages/core/src/DateInput/DateInputTouch.test.tsx (holds them equal) + */ + +import * as stylex from '@stylexjs/stylex'; +/** + * UIDatePicker's month/year toggle under a finger, from the UIKit constants + * table (motion/uikit/datePicker.ts `calendar.monthYearToggle`). The duration + * and curve are the `timing.stateChangeMs` and `timing.curves.move` motion + * roles, read as CSS variables with their table values as fallbacks. StyleX + * inlines only literal constants, so a test holds these to the table. + */ +export const monthYearToggleMotion = stylex.defineConsts({ + /** Every fade of the toggle and the chevron's turn: timing.stateChangeMs. */ + duration: 'var(--motion-timing-state-change-ms, 250ms)', + /** `UIView.animate`'s default curve: timing.curves.move. */ + curve: 'var(--motion-timing-curves-move, cubic-bezier(0.42, 0, 0.58, 1))', + /** The title chevron's quarter turn: chevronTurnDegrees. */ + chevronTurn: '90deg', +}); diff --git a/packages/core/src/DateInput/useChevronDip.test.tsx b/packages/core/src/DateInput/useChevronDip.test.tsx new file mode 100644 index 0000000000000..0008e1613e7f1 --- /dev/null +++ b/packages/core/src/DateInput/useChevronDip.test.tsx @@ -0,0 +1,83 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {render} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {useChevronDip} from './useChevronDip'; + +function Title({isOpen}: {isOpen: boolean}) { + const dipRef = useChevronDip(isOpen); + return ( + + ); +} + +describe('useChevronDip under Reduced Motion on a touch screen', () => { + const dips: { + cancel: ReturnType; + playState: string; + keyframes: Keyframe[]; + }[] = []; + beforeEach(() => { + dips.length = 0; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: + query.includes('prefers-reduced-motion: reduce') || + /pointer:\s*coarse/.test(query), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); + Element.prototype.animate = vi.fn((keyframes: Keyframe[]) => { + const dip = { + cancel: vi.fn(() => { + dip.playState = 'idle'; + }), + playState: 'running', + keyframes, + }; + dips.push(dip); + return dip; + }) as unknown as typeof Element.prototype.animate; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('cancels the running dip when the toggle changes again within it', () => { + const {rerender} = render(); + rerender(<Title isOpen />); + expect(dips).toHaveLength(1); + rerender(<Title isOpen={false} />); + expect(dips).toHaveLength(2); + expect(dips[0].cancel).toHaveBeenCalledTimes(1); + expect(dips[1].cancel).not.toHaveBeenCalled(); + }); + + it('starts a second dip within the first from the glyph’s current opacity', () => { + const {container, rerender} = render(<Title isOpen={false} />); + rerender(<Title isOpen />); + expect(dips[0].keyframes[0]).toEqual({opacity: 1}); + // Part way into the first dip. + const glyph = container.querySelector<HTMLElement>('.astryx-icon'); + if (glyph == null) { + throw new Error('no glyph'); + } + glyph.style.opacity = '0.4'; + rerender(<Title isOpen={false} />); + expect(dips[1].keyframes[0]).toEqual({opacity: 0.4}); + expect(dips[1].keyframes.slice(1)).toEqual([{opacity: 0}, {opacity: 1}]); + }); + + it('cancels its dip on unmount', () => { + const {rerender, unmount} = render(<Title isOpen={false} />); + rerender(<Title isOpen />); + unmount(); + expect(dips[0].cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/DateInput/useChevronDip.ts b/packages/core/src/DateInput/useChevronDip.ts new file mode 100644 index 0000000000000..ce655404a3821 --- /dev/null +++ b/packages/core/src/DateInput/useChevronDip.ts @@ -0,0 +1,84 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +'use client'; + +/** + * @file useChevronDip.ts + * @input Whether the month/year wheels show, and the Reduced Motion policy + * (motion/reducedMotion.ts) + * @output Exports `useChevronDip`: a callback ref for the title button whose + * chevron dips to transparent and back under reduced motion on a touch + * screen + * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx + * + * Under reduced motion the chevron must not turn. On a touch screen it dips + * instead, and its styles apply the turn at the dip's middle. + * + * SYNC: When modified, update MonthYearToggle.interaction.browser.spec.ts. + */ + +import {useEffect, useRef} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {reducedMotionFade} from '../motion/reducedMotion'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; + +/** + * A callback ref for the title button. Under reduced motion on a touch screen, + * its chevron dips to transparent and back each time `state` changes. The ref + * sits on the button because a registry Icon does not forward one. + * + * @example + * ``` + * const dipRef = useChevronDip(isWheelOpen); + * return <button ref={dipRef}><Icon icon="chevronDown" /></button>; + * ``` + */ +export function useChevronDip( + state: boolean, +): (element: Element | null) => void { + const elementRef = useRef<Element | null>(null); + const stateRef = useRef(state); + const dipRef = useRef<Animation | null>(null); + // Synchronizes the chevron's Web Animation with `state`: each change dips + // it once. + useIsomorphicLayoutEffect(() => { + if (stateRef.current === state) { + return; + } + stateRef.current = state; + const element = elementRef.current?.querySelector('.astryx-icon') ?? null; + const fade = reducedMotionFade(element); + if ( + element == null || + fade == null || + fade.duration === 0 || + typeof element.animate !== 'function' + ) { + return; + } + // A second change within the dip restarts it from the glyph's presented + // opacity, read before the running dip is cancelled. Only this case reads + // style. The fixed curve keeps the dip's middle on the turn's fixed + // delay (half the policy's duration) even when a theme retunes + // `timing.curves.move`. + const running = dipRef.current; + const from = + running != null && running.playState === 'running' + ? Number(getComputedStyle(element).opacity) + : 1; + running?.cancel(); + dipRef.current = element.animate( + [{opacity: from}, {opacity: 0}, {opacity: 1}], + {duration: fade.duration, easing: reducedMotionFadeConsts.easing}, + ); + }, [state]); + // Synchronizes with the dip's animation: it does not outlive the title. + useEffect( + () => () => { + dipRef.current?.cancel(); + }, + [], + ); + return element => { + elementRef.current = element; + }; +} diff --git a/packages/core/src/DateInput/useFadeInert.test.tsx b/packages/core/src/DateInput/useFadeInert.test.tsx new file mode 100644 index 0000000000000..3230d767803f7 --- /dev/null +++ b/packages/core/src/DateInput/useFadeInert.test.tsx @@ -0,0 +1,205 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {act, render} from '@testing-library/react'; +import {useLayoutEffect, useRef} from 'react'; +import {describe, expect, it} from 'vitest'; +import {useFadeInert} from './useFadeInert'; + +function Panels({ + isWheelOpen, + isDeferred = true, + onCommit, +}: { + isWheelOpen: boolean; + isDeferred?: boolean; + onCommit?: () => void; +}) { + // Every commit, to count the renders a toggle costs. + useLayoutEffect(() => { + onCommit?.(); + }); + const calendarRef = useRef<HTMLDivElement | null>(null); + const wheelsRef = useRef<HTMLDivElement | null>(null); + const {isCalendarInert, isWheelsInert} = useFadeInert( + isWheelOpen, + calendarRef, + wheelsRef, + isDeferred, + ); + return ( + <> + <div + ref={calendarRef} + data-testid="calendar" + aria-hidden={isWheelOpen ? true : undefined} + inert={isCalendarInert ? true : undefined}> + <button type="button">15</button> + </div> + <div + ref={wheelsRef} + data-testid="wheels" + aria-hidden={isWheelOpen ? undefined : true} + inert={isWheelsInert ? true : undefined}> + <div role="listbox" aria-label="Month" tabIndex={0} /> + </div> + </> + ); +} + +/** Makes `getAnimations()` report a running opacity transition on these. */ +function fading(...elements: HTMLElement[]) { + for (const element of elements) { + element.getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } +} + +async function fadeEnd(element: HTMLElement, type = 'transitionend') { + const event = new Event(type, {bubbles: true}); + Object.assign(event, {propertyName: 'opacity'}); + act(() => { + element.dispatchEvent(event); + }); + // The swap lands one frame after the end. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); +} + +describe('useFadeInert', () => { + it('makes the outgoing panel inert when its fade ends, never with the toggle', async () => { + const view = render(<Panels isWheelOpen={false} />); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + expect(wheels.hasAttribute('inert')).toBe(true); + fading(calendar, wheels); + view.rerender(<Panels isWheelOpen />); + // With the toggle: the outgoing panel leaves assistive technology, and + // neither panel's `inert` changes yet. + expect(calendar.getAttribute('aria-hidden')).toBe('true'); + expect(calendar.hasAttribute('inert')).toBe(false); + expect(wheels.hasAttribute('inert')).toBe(true); + // Another property's transition ending changes nothing. + const other = new Event('transitionend', {bubbles: true}); + Object.assign(other, {propertyName: 'transform'}); + act(() => { + calendar.dispatchEvent(other); + }); + expect(calendar.hasAttribute('inert')).toBe(false); + await fadeEnd(calendar); + expect(calendar.hasAttribute('inert')).toBe(true); + expect(wheels.hasAttribute('inert')).toBe(false); + }); + + it('waits for the visibility the layer beneath holds for the fade', async () => { + const view = render(<Panels isWheelOpen={false} />); + const calendar = view.getByTestId('calendar'); + calendar.getAnimations = () => + [ + {transitionProperty: 'visibility', playState: 'running'}, + ] as unknown as Animation[]; + view.rerender(<Panels isWheelOpen />); + expect(calendar.hasAttribute('inert')).toBe(false); + const end = new Event('transitionend', {bubbles: true}); + Object.assign(end, {propertyName: 'visibility'}); + act(() => { + calendar.dispatchEvent(end); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(calendar.hasAttribute('inert')).toBe(true); + }); + + it('swaps with the incoming panel\u2019s entrance when the outgoing one has no exit', async () => { + const view = render(<Panels isWheelOpen={false} />); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + // The calendar stays visible beneath (no exit); the wheels fade in. + calendar.getAnimations = () => []; + fading(wheels); + view.rerender(<Panels isWheelOpen />); + expect(calendar.hasAttribute('inert')).toBe(false); + await fadeEnd(wheels); + expect(calendar.hasAttribute('inert')).toBe(true); + expect(wheels.hasAttribute('inert')).toBe(false); + }); + + it('makes it inert on transitioncancel too', async () => { + const view = render(<Panels isWheelOpen />); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(<Panels isWheelOpen={false} />); + expect(wheels.hasAttribute('inert')).toBe(false); + expect(calendar.hasAttribute('inert')).toBe(true); + await fadeEnd(wheels, 'transitioncancel'); + expect(wheels.hasAttribute('inert')).toBe(true); + expect(calendar.hasAttribute('inert')).toBe(false); + }); + + it('swaps inert with the toggle with a mouse, fade or not', () => { + const view = render(<Panels isWheelOpen={false} isDeferred={false} />); + fading(view.getByTestId('calendar'), view.getByTestId('wheels')); + view.rerender(<Panels isWheelOpen isDeferred={false} />); + expect(view.getByTestId('calendar').hasAttribute('inert')).toBe(true); + expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); + }); + + it('swaps with the toggle in one commit with a mouse', () => { + let commits = 0; + const onCommit = () => { + commits += 1; + }; + const view = render( + <Panels isWheelOpen={false} isDeferred={false} onCommit={onCommit} />, + ); + commits = 0; + view.rerender( + <Panels isWheelOpen isDeferred={false} onCommit={onCommit} />, + ); + expect(commits).toBe(1); + view.rerender( + <Panels isWheelOpen={false} isDeferred={false} onCommit={onCommit} />, + ); + expect(commits).toBe(2); + }); + + it('makes it inert with the toggle when no fade runs', () => { + const view = render(<Panels isWheelOpen={false} />); + view.rerender(<Panels isWheelOpen />); + expect(view.getByTestId('calendar').hasAttribute('inert')).toBe(true); + expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); + }); + + it('moves focus inside the outgoing panel to the incoming one with the toggle', () => { + const view = render(<Panels isWheelOpen={false} />); + const calendar = view.getByTestId('calendar'); + fading(calendar, view.getByTestId('wheels')); + calendar.querySelector('button')!.focus(); + view.rerender(<Panels isWheelOpen />); + // The incoming panel goes live with the toggle to take focus. + expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); + expect(document.activeElement).toBe(view.getByRole('listbox')); + }); + + it('leaves focus outside the panels where it is', () => { + const view = render( + <> + <button type="button">Title</button> + <Panels isWheelOpen={false} /> + </>, + ); + const title = view.getByRole('button', {name: 'Title'}); + title.focus(); + view.rerender( + <> + <button type="button">Title</button> + <Panels isWheelOpen /> + </>, + ); + expect(document.activeElement).toBe(title); + }); +}); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts new file mode 100644 index 0000000000000..f0917bad35e60 --- /dev/null +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -0,0 +1,186 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +'use client'; + +/** + * @file useFadeInert.ts + * @input Whether the month/year wheels are open, and the two panels the + * month/year toggle cross-fades (the calendar and the wheels) + * @output Exports `useFadeInert`: which panel is `inert`, and whether the + * toggle's cross-fade is running + * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx + * + * Under a finger, the panels swap `inert` when the outgoing panel's exit + * ends. With a mouse they swap it with the toggle. Focus in the outgoing + * panel moves to the incoming one with the toggle. + */ + +import {useRef, useState, type RefObject} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; + +const FOCUSABLE = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * The properties that carry a panel's exit. The layer on top fades its + * `opacity`. The layer beneath holds `visibility` as long, then takes `hidden`. + */ +const EXIT_PROPERTIES = new Set(['opacity', 'visibility']); + +/** Whether the panel's own exit transition is running. */ +function isExiting(element: Element): boolean { + if (typeof element.getAnimations !== 'function') { + return false; + } + return element + .getAnimations() + .some(animation => + EXIT_PROPERTIES.has( + (animation as {transitionProperty?: string}).transitionProperty ?? '', + ), + ); +} + +/** + * `inert` is inherited, so flipping it restyles a panel's whole subtree; + * here both flips wait for the cross-fade to end, when nothing on screen + * moves. Meanwhile the caller's `aria-hidden` keeps assistive technology out + * of the outgoing panel. The caller's shield keeps touches off both, as a + * UIKit view animation does. + */ +export function useFadeInert( + isWheelOpen: boolean, + calendarRef: RefObject<HTMLElement | null>, + wheelsRef: RefObject<HTMLElement | null>, + /** + * Under a finger: wait for the fade. With a mouse the panels swap `inert` + * with the toggle, so a click right after it lands. + */ + isDeferred: boolean, +): {isCalendarInert: boolean; isWheelsInert: boolean; isFading: boolean} { + // `settled`: the toggle state whose exit has ended. `focusedFor`: the + // toggle state whose incoming panel took focus, and so is live early. + const [{settled, focusedFor}, setFade] = useState<{ + settled: boolean; + focusedFor: boolean | null; + }>({settled: isWheelOpen, focusedFor: null}); + // With a mouse the panels swap `inert` with the toggle. So `settled` + // follows `isWheelOpen` during render, React's pattern for state that + // tracks a prop, and the toggle costs one commit. + if (!isDeferred && settled !== isWheelOpen) { + setFade({settled: isWheelOpen, focusedFor: null}); + } + const isFading = settled !== isWheelOpen; + + // Synchronizes with the outgoing panel's exit: the swap of `inert` lands on + // its end. Nothing here reads style in the toggle's commit, which would + // resolve the fade's start there. The exit's own events end it. Two frames + // later, a panel with no exit running settles at once. + useIsomorphicLayoutEffect(() => { + if (!isFading) { + return; + } + const outgoing = (isWheelOpen ? calendarRef : wheelsRef).current; + if (outgoing == null || typeof outgoing.getAnimations !== 'function') { + // With no outgoing panel, or no Web Animations, no exit can run. + // eslint-disable-next-line @eslint-react/set-state-in-effect -- whether an exit can run is known only from the committed panel (its Web Animations support), which render cannot read + setFade({settled: isWheelOpen, focusedFor: null}); + return; + } + const incoming = (isWheelOpen ? wheelsRef : calendarRef).current; + const panels = [outgoing, incoming].filter( + (panel): panel is HTMLElement => panel != null, + ); + let frame = 0; + // One frame after the cross-fade's end, so the restyle lands after its + // last frame is on screen rather than holding that frame back. + const settle = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => + setFade({settled: isWheelOpen, focusedFor: null}), + ); + }; + // The cross-fade ends when either panel's own transition ends. The layer + // beneath may have none: it stays visible, covered. + const onEnd = (event: Event) => { + if ( + panels.includes(event.target as HTMLElement) && + EXIT_PROPERTIES.has((event as TransitionEvent).propertyName) + ) { + settle(); + } + }; + for (const panel of panels) { + panel.addEventListener('transitionend', onEnd); + panel.addEventListener('transitioncancel', onEnd); + } + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!panels.some(isExiting)) { + // No transition runs on either panel (one is not rendered, say). + setFade({settled: isWheelOpen, focusedFor: null}); + } + }); + }); + return () => { + cancelAnimationFrame(frame); + for (const panel of panels) { + panel.removeEventListener('transitionend', onEnd); + panel.removeEventListener('transitioncancel', onEnd); + } + }; + }, [isFading, isWheelOpen, calendarRef, wheelsRef]); + + // Synchronizes with the document's focus. Focus inside the outgoing panel + // moves to the incoming one with the toggle, never staying in a fading + // panel. The incoming panel goes live first (focus cannot + // land in an inert one), then takes focus. + const shownRef = useRef(isWheelOpen); + const pendingFocusRef = useRef<HTMLElement | null>(null); + useIsomorphicLayoutEffect(() => { + if (shownRef.current === isWheelOpen) { + return; + } + shownRef.current = isWheelOpen; + const outgoing = (isWheelOpen ? calendarRef : wheelsRef).current; + const incoming = (isWheelOpen ? wheelsRef : calendarRef).current; + const active = outgoing?.ownerDocument.activeElement ?? null; + if ( + outgoing == null || + incoming == null || + active == null || + !outgoing.contains(active) + ) { + return; + } + pendingFocusRef.current = incoming; + // eslint-disable-next-line @eslint-react/set-state-in-effect -- focus inside the outgoing panel is known only once the toggle has committed + setFade(fade => ({...fade, focusedFor: isWheelOpen})); + }, [isWheelOpen, calendarRef, wheelsRef]); + useIsomorphicLayoutEffect(() => { + const incoming = pendingFocusRef.current; + if (incoming == null || incoming.inert) { + return; + } + pendingFocusRef.current = null; + const target = incoming.querySelector<HTMLElement>(FOCUSABLE) ?? incoming; + target.focus({preventScroll: true}); + }); + + if (!isDeferred) { + return { + isCalendarInert: isWheelOpen, + isWheelsInert: !isWheelOpen, + isFading: false, + }; + } + const isIncomingLive = isFading && focusedFor === isWheelOpen; + return { + isCalendarInert: isFading + ? settled && !(isIncomingLive && !isWheelOpen) + : isWheelOpen, + isWheelsInert: isFading + ? !settled && !(isIncomingLive && isWheelOpen) + : !isWheelOpen, + isFading, + }; +} diff --git a/packages/core/src/DateInput/useWheelsWarmUp.test.tsx b/packages/core/src/DateInput/useWheelsWarmUp.test.tsx new file mode 100644 index 0000000000000..c3b90d9e433df --- /dev/null +++ b/packages/core/src/DateInput/useWheelsWarmUp.test.tsx @@ -0,0 +1,76 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useWheelsWarmUp.test.tsx + * @input useWheelsWarmUp, the motion math and the UIKit constants table + * @output Unit tests: the hidden wheels warm once the open sheet has settled + * (its present spring's settling duration) and the main thread is idle, and + * cool when it closes + * @position Colocated test of useWheelsWarmUp.ts + */ + +import {act, renderHook} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {settlingDuration, toSpring} from '../motion/spring'; +import {springs} from '../motion/uikit/springs'; +import {SHEET_SETTLE_MS, useWheelsWarmUp} from './useWheelsWarmUp'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('useWheelsWarmUp', () => { + it('waits for the sheet\u2019s present spring to settle', () => { + expect(SHEET_SETTLE_MS).toBe( + Math.round(settlingDuration(toSpring(springs.sheetPresent)) * 1000), + ); + expect(SHEET_SETTLE_MS).toBeGreaterThan(0); + }); + + it('warms once the open sheet has settled and the main thread is idle', () => { + const idle: (() => void)[] = []; + vi.stubGlobal('requestIdleCallback', (callback: () => void) => { + idle.push(callback); + return idle.length; + }); + vi.stubGlobal('cancelIdleCallback', () => {}); + const {result, rerender} = renderHook( + ({isOpen}) => useWheelsWarmUp(isOpen), + {initialProps: {isOpen: false}}, + ); + expect(result.current).toBe(false); + rerender({isOpen: true}); + act(() => { + vi.advanceTimersByTime(SHEET_SETTLE_MS - 1); + }); + expect(idle).toHaveLength(0); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(idle).toHaveLength(1); + expect(result.current).toBe(false); + act(() => { + idle[0]?.(); + }); + expect(result.current).toBe(true); + rerender({isOpen: false}); + expect(result.current).toBe(false); + }); + + it('never warms a sheet that closes before it settles', () => { + const {result, rerender} = renderHook( + ({isOpen}) => useWheelsWarmUp(isOpen), + {initialProps: {isOpen: true}}, + ); + rerender({isOpen: false}); + act(() => { + vi.advanceTimersByTime(SHEET_SETTLE_MS + 1000); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/core/src/DateInput/useWheelsWarmUp.ts b/packages/core/src/DateInput/useWheelsWarmUp.ts new file mode 100644 index 0000000000000..f4fba33c64505 --- /dev/null +++ b/packages/core/src/DateInput/useWheelsWarmUp.ts @@ -0,0 +1,74 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +'use client'; + +/** + * @file useWheelsWarmUp.ts + * @input Whether the sheet holding the month/year wheels is open + * @output Exports `useWheelsWarmUp`: true once the open sheet has settled and + * the main thread is idle, so the hidden wheels lay out before the toggle + * @position Internal hook of the pickers' touch layer (datePickerTouch.ts) + * + * WebKit builds a layer for every wheel row in the commit that shows them. + * Built inside the toggle's commit, that cost the fade's first frames. + * + * SYNC: When modified, update useWheelsWarmUp.test.tsx and + * MonthYearToggle.interaction.browser.spec.ts. + */ + +import {useEffect, useState} from 'react'; + +/** + * How long the sheet's present motion (`springs.sheetPresent`) takes to + * settle, in milliseconds. A constant, not a table read, so the pickers ship + * none of the springs table's other rows. The test pins it to the spring. + */ +export const SHEET_SETTLE_MS = 506; + +/** The longest an idle callback may wait before the wheels warm anyway. */ +const IDLE_TIMEOUT_MS = 300; + +/** + * True once the sheet has been open for its present motion's settling + * duration and the main thread has gone idle; false while it is closed. The + * caller then shows the hidden wheels at opacity 0, still inert, so the + * toggle's commit only changes opacity. + * + * @example + * ``` + * const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); + * ``` + */ +export function useWheelsWarmUp(isSheetOpen: boolean): boolean { + const [isWarm, setIsWarm] = useState(false); + // Synchronizes with the browser's clock and idle time. The wheels warm after + // the sheet's present motion settles and the main thread goes idle. Closing + // the sheet cancels both and cools them. + useEffect(() => { + if (!isSheetOpen) { + return undefined; + } + let idle: number | null = null; + const warm = () => { + setIsWarm(true); + }; + const timer = window.setTimeout(() => { + idle = + typeof window.requestIdleCallback === 'function' + ? window.requestIdleCallback(warm, {timeout: IDLE_TIMEOUT_MS}) + : window.setTimeout(warm, 0); + }, SHEET_SETTLE_MS); + return () => { + // Cooled for the next opening, which waits for its own settle. + setIsWarm(false); + window.clearTimeout(timer); + if (idle != null) { + if (typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(idle); + } else { + window.clearTimeout(idle); + } + } + }; + }, [isSheetOpen]); + return isSheetOpen && isWarm; +} diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index d4c4cc29b8d8d..95d10cb4943f7 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -873,6 +873,74 @@ describe('DateTimeInput touch surface', () => { }); }); + it('keeps the arrows and Reset out of reach while the month/year toggle fades', async () => { + withMonthLayout(() => { + render( + <DateTimeInput + label="Meeting" + value={'2026-03-15T10:00' as ISODateTimeString} + onChange={() => {}} + />, + ); + openDateSheet(); + }); + const surface = (which: 'calendar' | 'wheels') => { + const element = document.querySelector<HTMLElement>( + `[data-date-surface="${which}"]`, + ); + if (element == null) { + throw new Error(`no ${which} surface`); + } + return element; + }; + // A running exit on both surfaces, so their own `inert` waits for the + // fade's end (useFadeInert). + for (const which of ['calendar', 'wheels'] as const) { + surface(which).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + // Queried by attribute: role queries compute a name for every element of + // the open sheet. + const actions = () => + [ + document.querySelector('button[aria-label="Previous month"]'), + document.querySelector('button[aria-label="Next month"]'), + [...document.querySelectorAll('dialog[open] button')].find( + button => button.textContent?.trim() === 'Reset', + ), + ].map(button => button?.closest('[inert]') != null); + const title = document.querySelector<HTMLElement>( + 'button[aria-label$="Choose month and year"]', + ); + if (title == null) { + throw new Error('no month/year title'); + } + const fadeEnd = async (which: 'calendar' | 'wheels') => { + const event = new Event('transitionend', {bubbles: true}); + Object.assign(event, {propertyName: 'opacity'}); + act(() => { + surface(which).dispatchEvent(event); + }); + // The swap lands one frame after the end. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }; + + // Opening: the arrows and Reset leave with the toggle. + fireEvent.click(title); + expect(actions()).toEqual([true, true, true]); + await fadeEnd('calendar'); + expect(actions()).toEqual([true, true, true]); + // Closing: they wait for the fade, as the calendar does. + fireEvent.click(title); + expect(actions()).toEqual([true, true, true]); + await fadeEnd('wheels'); + expect(actions()).toEqual([false, false, false]); + }); + it('keeps Save date disabled until a valid date exists', () => { withMonthLayout(() => { render(<ControlledDateTimeInput />); diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index d6ca532725efb..65f527ae3777e 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -35,6 +35,7 @@ import { } from 'react'; import * as stylex from '@stylexjs/stylex'; import {inputDisabledFocusStyles} from '../Field/inputDisabledFocus'; +import type {StyleXStyles} from '@stylexjs/stylex'; import {BottomSheet} from '../BottomSheet'; import {Button} from '../Button'; import {useCalendarConstraints, type ISODateString} from '../Calendar'; @@ -102,7 +103,12 @@ import { } from '../DateInput/MonthScroller'; import {MonthYearWheels} from '../DateInput/MonthYearWheels'; import {Wheel, type WheelOption} from '../DateInput/Wheel'; -import {MonthPager, TimeWheels} from '../DateInput/datePickerTouch'; +import { + MonthPager, + TimeWheels, + useChevronDip, + useWheelsWarmUp, +} from '../DateInput/datePickerTouch'; import { hour12From24, hour24From12, @@ -112,6 +118,9 @@ import { secondRows, wheelBounds, } from '../TimeInput/timeWheelRules'; +import {useFadeInert} from '../DateInput/useFadeInert'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {monthYearToggleMotion} from '../DateInput/monthYearToggle.stylex'; import {useTouchPresentation} from '../utils/touchPresentation'; import { dateInputTouchGeometry, @@ -186,6 +195,17 @@ const sizeStyles = stylex.create({ const HORIZONTAL_SEGMENT_BASIS = 196; +/** + * UIDatePicker's month/year toggle under a finger: its fades and the chevron's + * quarter turn, 0.25 s on ease-in-ease-out (DateInput's + * monthYearToggle.stylex.ts). + */ +const TOGGLE_DURATION = monthYearToggleMotion.duration; +const TOGGLE_CURVE = monthYearToggleMotion.curve; +const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; +/** Under reduced motion the chevron's turn applies at its dip's middle. */ +const RM_DIP_HALF = `calc(${reducedMotionFadeConsts.duration} / 2)`; + const styles = stylex.create({ iconButton: { display: 'flex', @@ -327,11 +347,37 @@ const styles = stylex.create({ alignItems: 'center', gap: spacingVars['--spacing-0-5'], marginInlineStart: 'auto', + // Under a finger the month/year toggle is UIDatePicker's cross-fade: + // 0.25 s on Core Animation's ease-in-ease-out. With a mouse the swap stays + // instant. Under Reduced Motion a touch screen gets the policy's + // cross-fade and a mouse the instant swap. + transitionProperty: { + default: 'none', + '@media (pointer: coarse)': 'opacity, visibility', + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + 'none', + }, + transitionDuration: { + default: null, + '@media (pointer: coarse)': TOGGLE_DURATION, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, + // No `pointerEvents` in these two: it is inherited, so flipping it in the + // toggle's commit would restyle the calendar before the fade's first frame. + // `inert` and `visibility` cover it. touchHeaderActionsHidden: { visibility: 'hidden', opacity: 0, - pointerEvents: 'none', }, touchArrow: { minBlockSize: { @@ -359,6 +405,21 @@ const styles = stylex.create({ display: 'grid', inlineSize: '100%', minInlineSize: 0, + position: 'relative', + }, + /** + * While the month/year toggle cross-fades, a shield over both surfaces + * takes touches, as a UIKit view animation ignores them. `inert` swaps only + * at the fade's end (useFadeInert). A pseudo-element, so showing it + * restyles nothing inside the surfaces. + */ + touchDateSurfaceStackFading: { + '::after': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: 3, + }, }, touchDateSurface: { gridArea: '1 / 1', @@ -367,11 +428,86 @@ const styles = stylex.create({ minInlineSize: 0, flexDirection: 'column', gap: spacingVars['--spacing-2'], + // Under a finger the month/year toggle is UIDatePicker's cross-fade: + // 0.25 s on Core Animation's ease-in-ease-out. With a mouse the swap stays + // instant. Under Reduced Motion a touch screen gets the policy's + // cross-fade and a mouse the instant swap. + transitionProperty: { + default: 'none', + '@media (pointer: coarse)': 'opacity, visibility', + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + 'none', + }, + transitionDuration: { + default: null, + '@media (pointer: coarse)': TOGGLE_DURATION, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.duration, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, touchDateSurfaceHidden: { visibility: 'hidden', opacity: 0, - pointerEvents: 'none', + }, + // The hidden wheels under a finger once the sheet has settled: laid out, at + // opacity 0, so the toggle's commit only changes their opacity + // (useWheelsWarmUp). Still inert and hidden from assistive technology. + touchDateSurfaceWarm: { + visibility: 'visible', + }, + // Under a finger, UIDatePicker's chevron: it points along the reading + // direction for the calendar and turns down on the toggle's clock. With a + // mouse it points down and stays. Under Reduced Motion it does not turn. + // The turn applies mid-dip on a touch screen (useChevronDip). + touchTitleChevron: { + display: 'inline-flex', + flexShrink: 0, + transform: { + default: 'none', + '@media (pointer: coarse)': { + default: `rotate(-${CHEVRON_TURN})`, + // Mirrored under RTL, so it points along the reading direction; the + // same function list in both states keeps the turn a turn. + ':is([dir="rtl"] *)': `scaleX(-1) rotate(-${CHEVRON_TURN})`, + }, + }, + transitionProperty: { + default: 'none', + '@media (pointer: coarse)': 'transform', + }, + transitionDuration: { + default: null, + '@media (pointer: coarse)': TOGGLE_DURATION, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionDelay: { + default: '0s', + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + RM_DIP_HALF, + }, + transitionTimingFunction: { + default: 'ease', + '@media (pointer: coarse)': TOGGLE_CURVE, + }, + }, + touchTitleChevronOpen: { + transform: { + default: 'none', + '@media (pointer: coarse)': { + default: 'rotate(0deg)', + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)', + }, + }, }, touchWeekdays: { display: 'grid', @@ -566,8 +702,27 @@ export function TouchDateTimeField({ ); const [activePanel, setActivePanel] = useState<TouchDateTimePanel>('date'); const [isWheelOpen, setIsWheelOpen] = useState(false); + // The surfaces swap `inert` when the toggle's fade ends (useFadeInert), + // since flipping it restyles a surface's subtree. `aria-hidden` follows the + // toggle at once, and a shield takes touches while the fade runs. + const calendarSurfaceRef = useRef<HTMLDivElement | null>(null); + const wheelsSurfaceRef = useRef<HTMLDivElement | null>(null); const {isTouch} = useTouchPresentation(); + const {isCalendarInert, isWheelsInert, isFading} = useFadeInert( + isWheelOpen, + calendarSurfaceRef, + wheelsSurfaceRef, + isTouch, + ); + // The header's arrows and Reset leave with the toggle and arrive with the + // fade's end. They sit outside the surfaces' shield, so they go inert at + // once. + const isCalendarActionsInert = isWheelOpen || isCalendarInert; const MonthGrid = isTouch ? MonthPager : MonthScroller; + // Once the sheet settles, the hidden wheels are laid out at opacity 0, so + // the toggle itself only changes opacity. + const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); + const chevronDipRef = useChevronDip(isWheelOpen); const [draftTime, setDraftTime] = useState<ISOTimeString | undefined>(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1027,6 +1182,7 @@ export function TouchDateTimeField({ )}> <div {...stylex.props(styles.touchHeader)}> <button + ref={chevronDipRef} type="button" onClick={() => setIsWheelOpen(open => !open)} aria-expanded={isWheelOpen} @@ -1040,10 +1196,22 @@ export function TouchDateTimeField({ <span {...stylex.props(styles.touchTitleText)}> {monthYearLabel} </span> - <Icon icon="chevronDown" size="sm" color="secondary" /> + <Icon + icon="chevronDown" + size="sm" + color="secondary" + // StyleX's types do not model the touch key's nested RTL + // condition, so the styles are cast; they compile as written. + xstyle={ + [ + styles.touchTitleChevron, + isWheelOpen && styles.touchTitleChevronOpen, + ] as StyleXStyles + } + /> </button> <span - inert={isWheelOpen ? true : undefined} + inert={isCalendarActionsInert ? true : undefined} {...stylex.props( styles.touchHeaderActions, isWheelOpen && styles.touchHeaderActionsHidden, @@ -1092,11 +1260,16 @@ export function TouchDateTimeField({ </span> </div> - <div {...stylex.props(styles.touchDateSurfaceStack)}> + <div + {...stylex.props( + styles.touchDateSurfaceStack, + isFading && styles.touchDateSurfaceStackFading, + )}> <div + ref={calendarSurfaceRef} data-date-surface="calendar" aria-hidden={isWheelOpen ? 'true' : undefined} - inert={isWheelOpen ? true : undefined} + inert={isCalendarInert ? true : undefined} {...stylex.props( styles.touchDateSurface, isWheelOpen && styles.touchDateSurfaceHidden, @@ -1145,12 +1318,15 @@ export function TouchDateTimeField({ </div> <div + ref={wheelsSurfaceRef} data-date-surface="wheels" aria-hidden={!isWheelOpen ? 'true' : undefined} - inert={!isWheelOpen ? true : undefined} + inert={isWheelsInert ? true : undefined} {...stylex.props( styles.touchDateSurface, !isWheelOpen && styles.touchDateSurfaceHidden, + // Laid out ahead of the toggle, at opacity 0 (useWheelsWarmUp). + !isWheelOpen && isWheelsWarm && styles.touchDateSurfaceWarm, )}> <div aria-hidden="true" diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index b81a7b4845647..edfc646d4d1a5 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -276,8 +276,9 @@ table only names them, and their values belong to the wheel's own table. Done (UIKit lab, iOS 26.2 simulator, Oct 23 2026; in the date picker's table): the month/year wheels changing the date, the AM/PM column following the hour, -the turn back onto a bound, the day clamped to the month, the title following -the drag, and the chevron's retarget and its state at a bound. The sheet split +the turn back onto a bound, the day clamped to the month, a day tap, the +month/year toggle's fades and chevron turn, the title following the drag, and +the chevron's retarget and its state at a bound. The sheet split (M-DP01: 100 pt drags from the grid at 45° to 55°, slow and fast, logged per touch sample) followed on Oct 25 2026. A drag is decided on the first sample where either axis reaches the pan's hysteresis: the grid's when the horizontal diff --git a/packages/core/src/motion/uikit/datePicker.ts b/packages/core/src/motion/uikit/datePicker.ts index d7108615472fd..a046aea22483d 100644 --- a/packages/core/src/motion/uikit/datePicker.ts +++ b/packages/core/src/motion/uikit/datePicker.ts @@ -4,7 +4,7 @@ * @file datePicker.ts * @input Nothing; data only * @output Exports `datePicker`: the rules UIDatePicker adds to its calendar - * and wheels. The calendar's month grid pages like a paging scroll view. + * and wheels. Its calendar pages, selects and toggles to the wheels. * The month/year wheels change the date, the AM/PM column follows the hour, * a column turns back onto a bound, and a day clamps to its month. * @position The date picker domain of the UIKit constants table @@ -17,6 +17,40 @@ export const datePicker = { calendar: { + selection: { + /** + * A tap on a day selects it at once, with no animation. The new day's + * circle and numeral take their style in the frame of the lift, and + * `valueChanged` fires then. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * taps on two days with every CAAnimation logged (none on the day + * cells) and `valueChanged` timestamps 81–95 ms after touch-down, + * Oct 23 2026 + */ + tapAnimates: false, + }, + monthYearToggle: { + /** + * A tap on the title fades the month and year wheels in while the day + * grid, the weekday row and both chevrons fade out. Each fade takes + * 0.25 s on UIKit's default curve, and a second tap reverses it. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * title taps opening and closing the wheels: five CABasicAnimation + * opacity 0.25 s (`_UICalendarMonthYearSelector`, the + * `UICollectionView`, `_UICalendarWeekdayView`, both buttons) under + * one `UIView.animate` 0.25 s with options 4, Oct 23 2026 + */ + fadeMs: 250, + /** + * The title's chevron turns a quarter turn with the fades. It points + * along the reading direction for the calendar and down for the wheels. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * the title's UIImageView: CABasicAnimation transform 0.25 s from a + * −90° rotation on opening and from +90° on closing, to identity, + * Oct 23 2026 + */ + chevronTurnDegrees: 90, + }, pager: { /** * While a finger drags the month grid, the title names the month From 9c453d6d5666f3f244ff1e8582cf7db8058cad33 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:40 -0800 Subject: [PATCH 15/40] fix(DateInput, DateTimeInput): the toggle's handler decides the focus hand-off --- .../src/DateInput/DateInputTouch.test.tsx | 47 +++++++++++- .../core/src/DateInput/TouchDateField.tsx | 12 +++- .../core/src/DateInput/useFadeInert.test.tsx | 66 +++++++++++------ packages/core/src/DateInput/useFadeInert.ts | 71 +++++++++++-------- .../DateTimeInput/DateTimeInputTouch.test.tsx | 57 +++++++++++++++ .../src/DateTimeInput/TouchDateTimeField.tsx | 12 +++- 6 files changed, 206 insertions(+), 59 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 0fcd0e54d8365..8d27c28c53fdd 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -30,6 +30,7 @@ import { } from 'vitest'; import { act, + cleanup, render, screen, fireEvent, @@ -39,7 +40,7 @@ import { import {readFileSync} from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {useState} from 'react'; +import {Profiler, useState} from 'react'; import type {ISODateString} from '../utils'; import {InputGroup} from '../InputGroup'; import {InternationalizationProvider} from '../i18n'; @@ -2417,6 +2418,50 @@ describe('DateInput — month/year wheels', () => { expect(actions()).toEqual([false, false, false, true]); }); + it('a title press hands focus from the calendar to the wheels in the toggle\u2019s commit', () => { + // One title press with focus on `target`: its commits, and where focus ends. + const press = (target: () => HTMLElement) => { + let commits = 0; + withLayout(() => { + render( + <Profiler + id="sheet" + onRender={() => { + commits += 1; + }}> + <Controlled initial="2026-03-21" /> + </Profiler>, + ); + fireEvent.click(field()); + }); + // A running exit on both panels, so neither settles inside the press. + for (const which of ['calendar', 'wheels'] as const) { + (panel(which) as HTMLElement).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + target().focus(); + commits = 0; + openWheels(); + const result = { + commits, + isInWheels: panel('wheels').contains(document.activeElement), + }; + cleanup(); + return result; + }; + // An assistive technology can press the title while focus stays on a day. + const fromDay = press(() => + panel('calendar').querySelector<HTMLElement>('[data-date]')!, + ); + const fromTitle = press(title); + expect(fromDay.isInWheels).toBe(true); + expect(fromTitle.isInWheels).toBe(false); + // The hand-off rides the toggle's own commit and costs none of its own. + expect(fromDay.commits).toBe(fromTitle.commits); + }); + it('the year wheel keeps the month', () => { renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />); openWheels(); diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 46270397a24d0..a0d1940a54872 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -819,7 +819,7 @@ export function TouchDateField({ const calendarPanelRef = useRef<HTMLDivElement | null>(null); const wheelsPanelRef = useRef<HTMLDivElement | null>(null); const {isTouch} = useTouchPresentation(); - const {isCalendarInert, isWheelsInert, isFading} = useFadeInert( + const {isCalendarInert, isWheelsInert, isFading, followFocus} = useFadeInert( isWheelOpen, calendarPanelRef, wheelsPanelRef, @@ -1241,7 +1241,10 @@ export function TouchDateField({ // A live hidden wheel would hold the compositor to the main thread during // the fade. The buttons keep their identity, so React skips them. const closeSheet = useCallback(() => setIsSheetOpen(false), []); - const closeWheels = useCallback(() => setIsWheelOpen(false), []); + const closeWheels = useCallback(() => { + followFocus(false); + setIsWheelOpen(false); + }, [followFocus]); const monthArrowButtons = useMemo( () => ( <> @@ -1358,7 +1361,10 @@ export function TouchDateField({ <button ref={monthYearButtonRef} type="button" - onClick={() => setIsWheelOpen(open => !open)} + onClick={() => { + followFocus(!isWheelOpen); + setIsWheelOpen(!isWheelOpen); + }} aria-expanded={isWheelOpen} // One string, not a template: the comma and the word order are // the translator's to choose, not English's. diff --git a/packages/core/src/DateInput/useFadeInert.test.tsx b/packages/core/src/DateInput/useFadeInert.test.tsx index 3230d767803f7..6a399aa67e5e3 100644 --- a/packages/core/src/DateInput/useFadeInert.test.tsx +++ b/packages/core/src/DateInput/useFadeInert.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {act, render} from '@testing-library/react'; -import {useLayoutEffect, useRef} from 'react'; +import {act, fireEvent, render} from '@testing-library/react'; +import {useLayoutEffect, useRef, useState} from 'react'; import {describe, expect, it} from 'vitest'; import {useFadeInert} from './useFadeInert'; @@ -9,10 +9,13 @@ function Panels({ isWheelOpen, isDeferred = true, onCommit, + onToggle, }: { isWheelOpen: boolean; isDeferred?: boolean; onCommit?: () => void; + /** Renders a toggle that hands focus over, as the sheets' handlers do. */ + onToggle?: (next: boolean) => void; }) { // Every commit, to count the renders a toggle costs. useLayoutEffect(() => { @@ -20,7 +23,7 @@ function Panels({ }); const calendarRef = useRef<HTMLDivElement | null>(null); const wheelsRef = useRef<HTMLDivElement | null>(null); - const {isCalendarInert, isWheelsInert} = useFadeInert( + const {isCalendarInert, isWheelsInert, followFocus} = useFadeInert( isWheelOpen, calendarRef, wheelsRef, @@ -28,6 +31,16 @@ function Panels({ ); return ( <> + {onToggle != null && ( + <button + type="button" + onClick={() => { + followFocus(!isWheelOpen); + onToggle(!isWheelOpen); + }}> + Toggle + </button> + )} <div ref={calendarRef} data-testid="calendar" @@ -46,6 +59,18 @@ function Panels({ ); } +/** Panels behind a toggle that owns the state. */ +function TogglePanels({onCommit}: {onCommit?: () => void}) { + const [isWheelOpen, setIsWheelOpen] = useState(false); + return ( + <Panels + isWheelOpen={isWheelOpen} + onCommit={onCommit} + onToggle={setIsWheelOpen} + /> + ); +} + /** Makes `getAnimations()` report a running opacity transition on these. */ function fading(...elements: HTMLElement[]) { for (const element of elements) { @@ -174,32 +199,31 @@ describe('useFadeInert', () => { expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); }); - it('moves focus inside the outgoing panel to the incoming one with the toggle', () => { - const view = render(<Panels isWheelOpen={false} />); + it('moves focus inside the outgoing panel to the incoming one in the toggle\u2019s commit', () => { + let commits = 0; + const view = render( + <TogglePanels + onCommit={() => { + commits += 1; + }} + />, + ); const calendar = view.getByTestId('calendar'); fading(calendar, view.getByTestId('wheels')); calendar.querySelector('button')!.focus(); - view.rerender(<Panels isWheelOpen />); + commits = 0; + fireEvent.click(view.getByRole('button', {name: 'Toggle'})); // The incoming panel goes live with the toggle to take focus. expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); expect(document.activeElement).toBe(view.getByRole('listbox')); + expect(commits).toBe(1); }); it('leaves focus outside the panels where it is', () => { - const view = render( - <> - <button type="button">Title</button> - <Panels isWheelOpen={false} /> - </>, - ); - const title = view.getByRole('button', {name: 'Title'}); - title.focus(); - view.rerender( - <> - <button type="button">Title</button> - <Panels isWheelOpen /> - </>, - ); - expect(document.activeElement).toBe(title); + const view = render(<TogglePanels />); + const toggle = view.getByRole('button', {name: 'Toggle'}); + toggle.focus(); + fireEvent.click(toggle); + expect(document.activeElement).toBe(toggle); }); }); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts index f0917bad35e60..7facd1f8cbef8 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -5,16 +5,16 @@ * @file useFadeInert.ts * @input Whether the month/year wheels are open, and the two panels the * month/year toggle cross-fades (the calendar and the wheels) - * @output Exports `useFadeInert`: which panel is `inert`, and whether the - * toggle's cross-fade is running + * @output Exports `useFadeInert`: which panel is `inert`, whether the + * toggle's cross-fade runs, and the toggle's focus hand-off * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx * * Under a finger, the panels swap `inert` when the outgoing panel's exit - * ends. With a mouse they swap it with the toggle. Focus in the outgoing - * panel moves to the incoming one with the toggle. + * ends. With a mouse they swap it with the toggle. The toggle's handler calls + * `followFocus`, so focus leaves the outgoing panel in the toggle's commit. */ -import {useRef, useState, type RefObject} from 'react'; +import {useCallback, useRef, useState, type RefObject} from 'react'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; const FOCUSABLE = @@ -56,7 +56,12 @@ export function useFadeInert( * with the toggle, so a click right after it lands. */ isDeferred: boolean, -): {isCalendarInert: boolean; isWheelsInert: boolean; isFading: boolean} { +): { + isCalendarInert: boolean; + isWheelsInert: boolean; + isFading: boolean; + followFocus: (next: boolean) => void; +} { // `settled`: the toggle state whose exit has ended. `focusedFor`: the // toggle state whose incoming panel took focus, and so is live early. const [{settled, focusedFor}, setFade] = useState<{ @@ -130,32 +135,34 @@ export function useFadeInert( }; }, [isFading, isWheelOpen, calendarRef, wheelsRef]); - // Synchronizes with the document's focus. Focus inside the outgoing panel - // moves to the incoming one with the toggle, never staying in a fading - // panel. The incoming panel goes live first (focus cannot - // land in an inert one), then takes focus. - const shownRef = useRef(isWheelOpen); const pendingFocusRef = useRef<HTMLElement | null>(null); - useIsomorphicLayoutEffect(() => { - if (shownRef.current === isWheelOpen) { - return; - } - shownRef.current = isWheelOpen; - const outgoing = (isWheelOpen ? calendarRef : wheelsRef).current; - const incoming = (isWheelOpen ? wheelsRef : calendarRef).current; - const active = outgoing?.ownerDocument.activeElement ?? null; - if ( - outgoing == null || - incoming == null || - active == null || - !outgoing.contains(active) - ) { - return; - } - pendingFocusRef.current = incoming; - // eslint-disable-next-line @eslint-react/set-state-in-effect -- focus inside the outgoing panel is known only once the toggle has committed - setFade(fade => ({...fade, focusedFor: isWheelOpen})); - }, [isWheelOpen, calendarRef, wheelsRef]); + /** + * The toggle's event handler calls this before it changes the state, with + * the state it sets. Focus inside the outgoing panel then moves to the + * incoming one in the toggle's commit, never staying in a fading panel. + */ + const followFocus = useCallback( + (next: boolean) => { + const outgoing = (next ? calendarRef : wheelsRef).current; + const incoming = (next ? wheelsRef : calendarRef).current; + const active = outgoing?.ownerDocument.activeElement ?? null; + if ( + outgoing == null || + incoming == null || + active == null || + !outgoing.contains(active) + ) { + return; + } + pendingFocusRef.current = incoming; + // The incoming panel goes live with the toggle: focus cannot land in an + // inert one. + setFade(fade => ({...fade, focusedFor: next})); + }, + [calendarRef, wheelsRef], + ); + // Synchronizes with the document's focus: the incoming panel takes it once + // it is live. useIsomorphicLayoutEffect(() => { const incoming = pendingFocusRef.current; if (incoming == null || incoming.inert) { @@ -171,6 +178,7 @@ export function useFadeInert( isCalendarInert: isWheelOpen, isWheelsInert: !isWheelOpen, isFading: false, + followFocus, }; } const isIncomingLive = isFading && focusedFor === isWheelOpen; @@ -182,5 +190,6 @@ export function useFadeInert( ? !settled && !(isIncomingLive && isWheelOpen) : !isWheelOpen, isFading, + followFocus, }; } diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index 95d10cb4943f7..eca561cad2e43 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -29,6 +29,7 @@ import {Profiler, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; import {readFileSync} from 'node:fs'; import { + cleanup, act, render, screen, @@ -941,6 +942,62 @@ describe('DateTimeInput touch surface', () => { expect(actions()).toEqual([false, false, false]); }); + it('Done hands focus from the wheels to the calendar in the toggle\u2019s commit', () => { + // One Done press with focus on Done or on the title first: its commits, + // and where focus ends. + const pressDone = (isFocusOnDone: boolean) => { + let commits = 0; + withMonthLayout(() => { + render( + <Profiler + id="sheet" + onRender={() => { + commits += 1; + }}> + <DateTimeInput + label="Meeting" + value={'2026-03-15T10:00' as ISODateTimeString} + onChange={() => {}} + /> + </Profiler>, + ); + openDateSheet(); + }); + const surface = (which: 'calendar' | 'wheels') => + document.querySelector<HTMLElement>(`[data-date-surface="${which}"]`)!; + const title = document.querySelector<HTMLElement>( + 'button[aria-label$="Choose month and year"]', + )!; + fireEvent.click(title); + // Done sits in the wheels' footer, so a tap on it leaves focus there. + const done = [...surface('wheels').querySelectorAll('button')].find( + button => button.textContent?.trim() === 'Done', + )!; + (isFocusOnDone ? done : title).focus(); + // A running exit on both surfaces, so neither settles inside the press. + for (const which of ['calendar', 'wheels'] as const) { + surface(which).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + commits = 0; + fireEvent.click(done); + const result = { + commits, + isInCalendar: surface('calendar').contains(document.activeElement), + }; + cleanup(); + return result; + }; + const fromDone = pressDone(true); + const fromTitle = pressDone(false); + expect(fromDone.isInCalendar).toBe(true); + expect(fromTitle.isInCalendar).toBe(false); + // The hand-off rides the toggle's own commit and costs none of its own. + expect(fromDone.commits).toBe(fromTitle.commits); + }); + it('keeps Save date disabled until a valid date exists', () => { withMonthLayout(() => { render(<ControlledDateTimeInput />); diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index 65f527ae3777e..190ed49064648 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -708,7 +708,7 @@ export function TouchDateTimeField({ const calendarSurfaceRef = useRef<HTMLDivElement | null>(null); const wheelsSurfaceRef = useRef<HTMLDivElement | null>(null); const {isTouch} = useTouchPresentation(); - const {isCalendarInert, isWheelsInert, isFading} = useFadeInert( + const {isCalendarInert, isWheelsInert, isFading, followFocus} = useFadeInert( isWheelOpen, calendarSurfaceRef, wheelsSurfaceRef, @@ -1184,7 +1184,10 @@ export function TouchDateTimeField({ <button ref={chevronDipRef} type="button" - onClick={() => setIsWheelOpen(open => !open)} + onClick={() => { + followFocus(!isWheelOpen); + setIsWheelOpen(!isWheelOpen); + }} aria-expanded={isWheelOpen} aria-label={t('@astryx.dateInput.chooseMonthYear', { monthYear: monthYearLabel, @@ -1349,7 +1352,10 @@ export function TouchDateTimeField({ size="md" width="100%" label={t('@astryx.dateInput.doneChoosingMonth')} - onClick={() => setIsWheelOpen(false)} + onClick={() => { + followFocus(false); + setIsWheelOpen(false); + }} /> </div> </div> From f613d131b6df15a8d945421ed6ee285b19060cc5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:40 -0800 Subject: [PATCH 16/40] fix(DateInput, DateTimeInput): Done hands focus to the shown month, and the chevron dips on the shared dip Done handed focus to the first focusable day of the calendar, a month mounted off-screen, and in Chromium the calendar was still hidden then, so focus fell to the page. It now takes the shown month's tab stop, retrying each frame until the calendar can take focus. Under Reduced Motion the chevron's dip is playReducedMotionDip, and its turn lands in the dip's landing callback instead of on a CSS delay. The title button takes #10231's month-and-year ref and the chevron's ref through useMergedRefs. --- .../calendar-toggle-uidatepicker-timing.md | 7 +- .../src/DateInput/DateInputTouch.test.tsx | 7 +- .../core/src/DateInput/TouchDateField.tsx | 23 ++-- ...onthYearToggle.interaction.browser.spec.ts | 54 +++++++-- .../core/src/DateInput/useChevronDip.test.tsx | 103 +++++++++------- packages/core/src/DateInput/useChevronDip.ts | 113 +++++++++--------- packages/core/src/DateInput/useFadeInert.ts | 51 ++++++-- .../DateTimeInput/DateTimeInputTouch.test.tsx | 6 +- .../src/DateTimeInput/TouchDateTimeField.tsx | 15 +-- 9 files changed, 237 insertions(+), 142 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index 0cd9a94f2ccdc..1c9cb412a4925 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -4,13 +4,14 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now follows the iOS date picker's timing: -- The month/year toggle cross-fades in 0.25 s on iOS's ease-in-out curve, where DateInput used a quick linear fade and DateTimeInput an instant swap. As in UIKit, nothing in the header or the footer takes a tap during the fade. +- The month/year toggle cross-fades in 0.25 s on iOS's ease-in-out curve, where DateInput used a quick linear fade and DateTimeInput an instant swap. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. - The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. -- The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show. In DateInput it was a down chevron that flipped up. +- The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A tapped day takes its selection at once, with no colour transition. +- When Done closes the wheels, the keyboard focus moves to the shown month's day. With a mouse, the toggle and the day's transition stay as they are. -With Reduce Motion on, nothing travels: under a finger the toggle cross-fades in 200 ms and the chevron dips instead of turning. With a mouse it changes at once. +With Reduce Motion on, nothing travels: under a finger the toggle cross-fades in 200 ms, and the chevron dips instead of turning, its turn landing mid-dip. With a mouse it changes at once. @vjeux diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 8d27c28c53fdd..98492046a95fb 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -3884,7 +3884,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { "default: 'rotate(0deg)'", ); expect(source).toContain( - 'const chevronDipRef = useChevronDip(isWheelOpen)', + 'const {ref: chevronRef, dip: dipChevron} = useChevronDip()', ); }); @@ -3914,9 +3914,10 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(chevron).toContain( "'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s'", ); - expect(chevron).toContain('RM_DIP_HALF'); + // The turn lands at the dip's middle (useChevronDip), not on a delay. + expect(chevron.includes('transitionDelay')).toBe(false); expect(source).toContain( - 'const chevronDipRef = useChevronDip(isWheelOpen)', + 'const {ref: chevronRef, dip: dipChevron} = useChevronDip()', ); }); diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index a0d1940a54872..455ebb8096b47 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -183,8 +183,6 @@ const SWAP_DURATION = durationVars['--duration-fast']; const TOGGLE_DURATION = monthYearToggleMotion.duration; const TOGGLE_CURVE = monthYearToggleMotion.curve; const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; -/** Under reduced motion the chevron's turn applies at its dip's middle. */ -const RM_DIP_HALF = `calc(${reducedMotionFadeConsts.duration} / 2)`; const sizeStyles = stylex.create({ sm: { @@ -453,17 +451,12 @@ const styles = stylex.create({ default: SWAP_DURATION, '@media (pointer: coarse)': TOGGLE_DURATION, // Under Reduced Motion the chevron does not turn. On a touch screen the - // turn applies at the middle of its opacity dip (useChevronDip), and - // with a mouse at once. + // turn lands at the middle of its opacity dip (useChevronDip), and with + // a mouse at once. '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': '0s', }, - transitionDelay: { - default: '0s', - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': - RM_DIP_HALF, - }, transitionTimingFunction: { default: easeVars['--ease-standard'], '@media (pointer: coarse)': TOGGLE_CURVE, @@ -835,11 +828,14 @@ export function TouchDateField({ // Once the sheet settles, the hidden wheels are laid out at opacity 0, so // the toggle itself only changes opacity. const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); - const chevronDipRef = useChevronDip(isWheelOpen); + const {ref: chevronRef, dip: dipChevron} = useChevronDip(); const scrollerHandleRef = useRef<MonthScrollerHandle | null>(null); const previousArrowRef = useRef<HTMLButtonElement | null>(null); const nextArrowRef = useRef<HTMLButtonElement | null>(null); const monthYearButtonRef = useRef<HTMLButtonElement | null>(null); + // The title button takes two refs: the chevron's dip reads it, and the + // month arrows hand focus to it when both hide. + const titleButtonRef = useMergedRefs(monthYearButtonRef, chevronRef); // Pending focus handoff from the clear button; see handleClear. const clearFocusTimerRef = useRef<number | null>(null); // Releases the focus timer on unmount. @@ -1243,8 +1239,9 @@ export function TouchDateField({ const closeSheet = useCallback(() => setIsSheetOpen(false), []); const closeWheels = useCallback(() => { followFocus(false); + dipChevron(); setIsWheelOpen(false); - }, [followFocus]); + }, [followFocus, dipChevron]); const monthArrowButtons = useMemo( () => ( <> @@ -1359,10 +1356,11 @@ export function TouchDateField({ <div {...stylex.props(styles.surface)}> <div {...stylex.props(styles.header)}> <button - ref={monthYearButtonRef} + ref={titleButtonRef} type="button" onClick={() => { followFocus(!isWheelOpen); + dipChevron(); setIsWheelOpen(!isWheelOpen); }} aria-expanded={isWheelOpen} @@ -1378,7 +1376,6 @@ export function TouchDateField({ // restyle the header button. Adding a target later is additive; // withdrawing one is not. data-title="month-year" - ref={chevronDipRef} {...pressable} {...stylex.props( styles.title, diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index dbc698029d3f3..4f27007fc3527 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -566,8 +566,8 @@ test('Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron di } expect(animation.duration).toBeLessThanOrEqual(200); } - // The chevron dips; its turn is a zero-length transition at the dip's - // middle, an instant change rather than travel. + // The chevron dips; its turn is no transition but an instant change that + // lands at the dip's middle, while the glyph is transparent. const chevron = ( await runningAnimations(page, '[data-title="month-year"] .astryx-icon') ).filter(animation => animation.duration > 0); @@ -576,15 +576,49 @@ test('Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron di expect(animation.properties).toEqual(['opacity']); expect(animation.duration).toBeLessThanOrEqual(200); } - const turn = await page - .locator('[data-title="month-year"] .astryx-icon') - .first() - .evaluate(element => { - const style = getComputedStyle(element); - return {duration: style.transitionDuration, delay: style.transitionDelay}; - }); - expect(turn).toEqual({duration: '0s', delay: '0.1s'}); + const icon = page.locator('[data-title="month-year"] .astryx-icon').first(); + const turn = await icon.evaluate(element => { + const style = getComputedStyle(element); + return {duration: style.transitionDuration, delay: style.transitionDelay}; + }); + expect(turn).toEqual({duration: '0s', delay: '0s'}); + await waitForRest(wheels); + // Once the dip has landed, the chevron shows the turn: it points down. + expect( + await icon.evaluate(element => getComputedStyle(element).transform), + ).toBe('matrix(1, 0, 0, 1, 0, 0)'); +}); + +test('DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus', async ({ + page, +}) => { + await openStory(page, storybook(), DTI_STORY); + const driver = await touchDriver(page); + const tapCenter = async (selector: string) => { + const box = await page.locator(selector).first().boundingBox(); + if (box == null) { + throw new Error(`no ${selector}`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter('button[aria-label^="Open "]'); + await waitForRest( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + ); + await tapCenter('button[aria-label$="Choose month and year"]'); + const wheels = page.locator('[data-date-surface="wheels"]').first(); await waitForRest(wheels); + // Done from the keyboard: focus sits in the wheels' footer. + const done = wheels.getByRole('button', {name: 'Done', exact: true}); + await done.focus(); + await page.keyboard.press('Enter'); + // The story's value is March 15, 2026: the shown month's tab stop. + const day = page.locator( + 'button[data-date="2026-03-15"]:not([data-outside])', + ); + await expect(day).toBeFocused(); + await expect(day).toHaveAttribute('tabindex', '0'); + await expect(day).toBeInViewport(); }); test('in RTL the chevron points left, and turns to point down', async ({ diff --git a/packages/core/src/DateInput/useChevronDip.test.tsx b/packages/core/src/DateInput/useChevronDip.test.tsx index 0008e1613e7f1..fcc07f8b682d3 100644 --- a/packages/core/src/DateInput/useChevronDip.test.tsx +++ b/packages/core/src/DateInput/useChevronDip.test.tsx @@ -1,26 +1,43 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {render} from '@testing-library/react'; +import {fireEvent, render} from '@testing-library/react'; +import {useState} from 'react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import * as reducedMotion from '../motion/reducedMotion'; import {useChevronDip} from './useChevronDip'; -function Title({isOpen}: {isOpen: boolean}) { - const dipRef = useChevronDip(isOpen); +vi.mock('../motion/reducedMotion', async importOriginal => ({ + ...(await importOriginal<typeof reducedMotion>()), + playReducedMotionDip: vi.fn(), +})); + +const {playReducedMotionDip} = reducedMotion; + +const CLOSED = 'matrix(0, -1, 1, 0, 0, 0)'; + +function Title() { + const [isOpen, setIsOpen] = useState(false); + const chevron = useChevronDip(); return ( - <button ref={dipRef} type="button"> + <button + ref={chevron.ref} + type="button" + data-open={isOpen} + onClick={() => { + chevron.dip(); + setIsOpen(!isOpen); + }}> <span className="astryx-icon" /> </button> ); } describe('useChevronDip under Reduced Motion on a touch screen', () => { - const dips: { - cancel: ReturnType<typeof vi.fn>; - playState: string; - keyframes: Keyframe[]; - }[] = []; + const dips: {cancel: ReturnType<typeof vi.fn>}[] = []; + let glyphOpacity = '1'; beforeEach(() => { dips.length = 0; + glyphOpacity = '1'; vi.stubGlobal('matchMedia', (query: string) => ({ matches: query.includes('prefers-reduced-motion: reduce') || @@ -33,51 +50,57 @@ describe('useChevronDip under Reduced Motion on a touch screen', () => { removeEventListener: () => {}, dispatchEvent: () => false, })); - Element.prototype.animate = vi.fn((keyframes: Keyframe[]) => { - const dip = { - cancel: vi.fn(() => { - dip.playState = 'idle'; - }), - playState: 'running', - keyframes, - }; + Element.prototype.animate = vi.fn() as typeof Element.prototype.animate; + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => + element.classList.contains('astryx-icon') + ? ({ + opacity: glyphOpacity, + transform: (element as HTMLElement).style.transform || CLOSED, + } as CSSStyleDeclaration) + : computed(element), + ); + vi.mocked(playReducedMotionDip).mockClear(); + vi.mocked(playReducedMotionDip).mockImplementation(() => { + const dip = {cancel: vi.fn()}; dips.push(dip); return dip; - }) as unknown as typeof Element.prototype.animate; + }); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); - it('cancels the running dip when the toggle changes again within it', () => { - const {rerender} = render(<Title isOpen={false} />); - rerender(<Title isOpen />); + it('holds the shown turn until the dip lands, then lets the new state turn it', () => { + const view = render(<Title />); + const glyph = view.container.querySelector<HTMLElement>('.astryx-icon')!; + fireEvent.click(view.getByRole('button')); expect(dips).toHaveLength(1); - rerender(<Title isOpen={false} />); - expect(dips).toHaveLength(2); - expect(dips[0].cancel).toHaveBeenCalledTimes(1); - expect(dips[1].cancel).not.toHaveBeenCalled(); + const [element, , land, from] = + vi.mocked(playReducedMotionDip).mock.calls[0]; + expect(element).toBe(glyph); + expect(from).toBe(1); + expect(glyph.style.transform).toBe(CLOSED); + land(); + expect(glyph.style.transform).toBe(''); }); - it('starts a second dip within the first from the glyph’s current opacity', () => { - const {container, rerender} = render(<Title isOpen={false} />); - rerender(<Title isOpen />); - expect(dips[0].keyframes[0]).toEqual({opacity: 1}); + it('restarts a second dip within the first from the glyph’s presented opacity, and cancels the first', () => { + const view = render(<Title />); + fireEvent.click(view.getByRole('button')); // Part way into the first dip. - const glyph = container.querySelector<HTMLElement>('.astryx-icon'); - if (glyph == null) { - throw new Error('no glyph'); - } - glyph.style.opacity = '0.4'; - rerender(<Title isOpen={false} />); - expect(dips[1].keyframes[0]).toEqual({opacity: 0.4}); - expect(dips[1].keyframes.slice(1)).toEqual([{opacity: 0}, {opacity: 1}]); + glyphOpacity = '0.4'; + fireEvent.click(view.getByRole('button')); + expect(dips).toHaveLength(2); + expect(dips[0].cancel).toHaveBeenCalledTimes(1); + expect(vi.mocked(playReducedMotionDip).mock.calls[1][3]).toBe(0.4); }); it('cancels its dip on unmount', () => { - const {rerender, unmount} = render(<Title isOpen={false} />); - rerender(<Title isOpen />); - unmount(); + const view = render(<Title />); + fireEvent.click(view.getByRole('button')); + view.unmount(); expect(dips[0].cancel).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/DateInput/useChevronDip.ts b/packages/core/src/DateInput/useChevronDip.ts index ce655404a3821..9c4016016a5af 100644 --- a/packages/core/src/DateInput/useChevronDip.ts +++ b/packages/core/src/DateInput/useChevronDip.ts @@ -3,82 +3,87 @@ /** * @file useChevronDip.ts - * @input Whether the month/year wheels show, and the Reduced Motion policy + * @input The title button, and the Reduced Motion policy * (motion/reducedMotion.ts) - * @output Exports `useChevronDip`: a callback ref for the title button whose - * chevron dips to transparent and back under reduced motion on a touch - * screen + * @output Exports `useChevronDip`: a callback ref for the title button, and + * `dip`, which the toggle's handler calls before it changes the state * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx * - * Under reduced motion the chevron must not turn. On a touch screen it dips - * instead, and its styles apply the turn at the dip's middle. + * Under reduced motion on a touch screen the chevron does not turn. It dips + * instead (playReducedMotionDip), and the turn lands at the dip's middle. * * SYNC: When modified, update MonthYearToggle.interaction.browser.spec.ts. */ -import {useEffect, useRef} from 'react'; -import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; -import {reducedMotionFade} from '../motion/reducedMotion'; -import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {useCallback, useEffect, useRef} from 'react'; +import { + playReducedMotionDip, + reducedMotionFade, + type ReducedMotionDip, +} from '../motion/reducedMotion'; + +/** The title chevron's Reduced Motion dip. */ +export interface ChevronDip { + /** A callback ref for the title button. */ + ref: (element: Element | null) => void; + /** + * The toggle's handler calls this before it changes the state. Under + * reduced motion on a touch screen, the chevron dips, and its turn lands at + * the dip's middle. Otherwise it does nothing. + */ + dip: () => void; +} /** - * A callback ref for the title button. Under reduced motion on a touch screen, - * its chevron dips to transparent and back each time `state` changes. The ref - * sits on the button because a registry Icon does not forward one. + * The title chevron's Reduced Motion dip. The ref sits on the button because + * a registry Icon does not forward one, and the dip finds the icon inside it. * * @example * ``` - * const dipRef = useChevronDip(isWheelOpen); - * return <button ref={dipRef}><Icon icon="chevronDown" /></button>; + * const chevron = useChevronDip(); + * const toggle = () => { + * chevron.dip(); + * setIsWheelOpen(!isWheelOpen); + * }; * ``` */ -export function useChevronDip( - state: boolean, -): (element: Element | null) => void { +export function useChevronDip(): ChevronDip { const elementRef = useRef<Element | null>(null); - const stateRef = useRef(state); - const dipRef = useRef<Animation | null>(null); - // Synchronizes the chevron's Web Animation with `state`: each change dips - // it once. - useIsomorphicLayoutEffect(() => { - if (stateRef.current === state) { - return; - } - stateRef.current = state; - const element = elementRef.current?.querySelector('.astryx-icon') ?? null; - const fade = reducedMotionFade(element); + const dipRef = useRef<ReducedMotionDip | null>(null); + const dip = useCallback(() => { + const icon = + elementRef.current?.querySelector<HTMLElement>('.astryx-icon') ?? null; + const fade = reducedMotionFade(icon); if ( - element == null || + icon == null || fade == null || fade.duration === 0 || - typeof element.animate !== 'function' + typeof icon.animate !== 'function' ) { return; } - // A second change within the dip restarts it from the glyph's presented - // opacity, read before the running dip is cancelled. Only this case reads - // style. The fixed curve keeps the dip's middle on the turn's fixed - // delay (half the policy's duration) even when a theme retunes - // `timing.curves.move`. - const running = dipRef.current; - const from = - running != null && running.playState === 'running' - ? Number(getComputedStyle(element).opacity) - : 1; - running?.cancel(); - dipRef.current = element.animate( - [{opacity: from}, {opacity: 0}, {opacity: 1}], - {duration: fade.duration, easing: reducedMotionFadeConsts.easing}, + const style = getComputedStyle(icon); + // A second change within a dip restarts it from the glyph's presented + // opacity. + const from = dipRef.current == null ? 1 : Number(style.opacity); + // The chevron keeps the turn it shows until the dip's middle: an inline + // transform holds it over the styles of the new state. + const shown = style.transform; + dipRef.current?.cancel(); + icon.style.transform = shown; + dipRef.current = playReducedMotionDip( + icon, + fade, + () => { + icon.style.transform = ''; + }, + from, ); - }, [state]); + }, []); // Synchronizes with the dip's animation: it does not outlive the title. - useEffect( - () => () => { - dipRef.current?.cancel(); - }, - [], - ); - return element => { + useEffect(() => () => dipRef.current?.cancel(), []); + const ref = useCallback((element: Element | null) => { elementRef.current = element; - }; + }, []); + return {ref, dip}; } diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts index 7facd1f8cbef8..d1c2cd650c2da 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -14,12 +14,21 @@ * `followFocus`, so focus leaves the outgoing panel in the toggle's commit. */ -import {useCallback, useRef, useState, type RefObject} from 'react'; +import {useCallback, useEffect, useRef, useState, type RefObject} from 'react'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; +/** A grid's one tab stop: in the calendar, the day of the shown month. */ +const TAB_STOP = '[tabindex="0"]'; + +/** + * Frames the hand-off tries for before it gives up: about half a second, + * longer than the toggle's 0.25 s fade. + */ +const FOCUS_FRAMES = 30; + /** * The properties that carry a panel's exit. The layer on top fades its * `opacity`. The layer beneath holds `visibility` as long, then takes `hidden`. @@ -135,7 +144,10 @@ export function useFadeInert( }; }, [isFading, isWheelOpen, calendarRef, wheelsRef]); - const pendingFocusRef = useRef<HTMLElement | null>(null); + // The incoming panel, and the element that held focus at the hand-off. + const pendingFocusRef = useRef<{incoming: HTMLElement; from: Element} | null>( + null, + ); /** * The toggle's event handler calls this before it changes the state, with * the state it sets. Focus inside the outgoing panel then moves to the @@ -154,24 +166,47 @@ export function useFadeInert( ) { return; } - pendingFocusRef.current = incoming; + pendingFocusRef.current = {incoming, from: active}; // The incoming panel goes live with the toggle: focus cannot land in an // inert one. setFade(fade => ({...fade, focusedFor: next})); }, [calendarRef, wheelsRef], ); + const focusFrameRef = useRef(0); // Synchronizes with the document's focus: the incoming panel takes it once - // it is live. + // it is live and can take focus. A panel that a `visibility` transition + // shows takes focus only once that transition starts, so it retries. useIsomorphicLayoutEffect(() => { - const incoming = pendingFocusRef.current; - if (incoming == null || incoming.inert) { + const pending = pendingFocusRef.current; + if (pending == null || pending.incoming.inert) { return; } pendingFocusRef.current = null; - const target = incoming.querySelector<HTMLElement>(FOCUSABLE) ?? incoming; - target.focus({preventScroll: true}); + const {incoming, from} = pending; + const target = + incoming.querySelector<HTMLElement>(TAB_STOP) ?? + incoming.querySelector<HTMLElement>(FOCUSABLE) ?? + incoming; + cancelAnimationFrame(focusFrameRef.current); + let frames = 0; + const tryFocus = () => { + const document = target.ownerDocument; + target.focus({preventScroll: true}); + frames += 1; + // It retries only while focus is still where the hand-off found it, or + // has fallen to the page. A tap or Tab elsewhere ends the hand-off. + const active = document.activeElement; + const isWaiting = + active == null || active === document.body || active === from; + if (isWaiting && target.isConnected && frames < FOCUS_FRAMES) { + focusFrameRef.current = requestAnimationFrame(tryFocus); + } + }; + tryFocus(); }); + // Synchronizes with the frame clock: a pending retry ends with the hook. + useEffect(() => () => cancelAnimationFrame(focusFrameRef.current), []); if (!isDeferred) { return { diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index eca561cad2e43..db48f6c538a88 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -942,7 +942,7 @@ describe('DateTimeInput touch surface', () => { expect(actions()).toEqual([false, false, false]); }); - it('Done hands focus from the wheels to the calendar in the toggle\u2019s commit', () => { + it('Done hands focus from the wheels to the calendar in the toggle’s commit', () => { // One Done press with focus on Done or on the title first: its commits, // and where focus ends. const pressDone = (isFocusOnDone: boolean) => { @@ -986,6 +986,8 @@ describe('DateTimeInput touch surface', () => { const result = { commits, isInCalendar: surface('calendar').contains(document.activeElement), + focusedDate: (document.activeElement as HTMLElement | null)?.dataset + .date, }; cleanup(); return result; @@ -993,6 +995,8 @@ describe('DateTimeInput touch surface', () => { const fromDone = pressDone(true); const fromTitle = pressDone(false); expect(fromDone.isInCalendar).toBe(true); + // The shown month's tab stop, not the first day mounted three months back. + expect(fromDone.focusedDate).toBe('2026-03-15'); expect(fromTitle.isInCalendar).toBe(false); // The hand-off rides the toggle's own commit and costs none of its own. expect(fromDone.commits).toBe(fromTitle.commits); diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index 190ed49064648..cc92119d65451 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -203,8 +203,6 @@ const HORIZONTAL_SEGMENT_BASIS = 196; const TOGGLE_DURATION = monthYearToggleMotion.duration; const TOGGLE_CURVE = monthYearToggleMotion.curve; const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; -/** Under reduced motion the chevron's turn applies at its dip's middle. */ -const RM_DIP_HALF = `calc(${reducedMotionFadeConsts.duration} / 2)`; const styles = stylex.create({ iconButton: { @@ -466,7 +464,7 @@ const styles = stylex.create({ // Under a finger, UIDatePicker's chevron: it points along the reading // direction for the calendar and turns down on the toggle's clock. With a // mouse it points down and stays. Under Reduced Motion it does not turn. - // The turn applies mid-dip on a touch screen (useChevronDip). + // The turn lands mid-dip on a touch screen (useChevronDip). touchTitleChevron: { display: 'inline-flex', flexShrink: 0, @@ -490,11 +488,6 @@ const styles = stylex.create({ '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': '0s', }, - transitionDelay: { - default: '0s', - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': - RM_DIP_HALF, - }, transitionTimingFunction: { default: 'ease', '@media (pointer: coarse)': TOGGLE_CURVE, @@ -722,7 +715,7 @@ export function TouchDateTimeField({ // Once the sheet settles, the hidden wheels are laid out at opacity 0, so // the toggle itself only changes opacity. const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); - const chevronDipRef = useChevronDip(isWheelOpen); + const {ref: chevronRef, dip: dipChevron} = useChevronDip(); const [draftTime, setDraftTime] = useState<ISOTimeString | undefined>(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1182,10 +1175,11 @@ export function TouchDateTimeField({ )}> <div {...stylex.props(styles.touchHeader)}> <button - ref={chevronDipRef} + ref={chevronRef} type="button" onClick={() => { followFocus(!isWheelOpen); + dipChevron(); setIsWheelOpen(!isWheelOpen); }} aria-expanded={isWheelOpen} @@ -1354,6 +1348,7 @@ export function TouchDateTimeField({ label={t('@astryx.dateInput.doneChoosingMonth')} onClick={() => { followFocus(false); + dipChevron(); setIsWheelOpen(false); }} /> From 289ef0023f25480f770a4917e1ca0bd86f96857e Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:40 -0800 Subject: [PATCH 17/40] spec(DateInput, DateTimeInput): the title chevron and Done's focus hand-off DateInput FR24 records the title chevron's direction, its quarter turn and its Reduced Motion dip under a finger. DateTimeInput FR15 follows it, and records that Done hands focus to the shown month's tab stop. --- .../components/DateInput/DateInput.spec.md | 20 ++++++++++--------- .../DateTimeInput/DateTimeInput.spec.md | 2 ++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 938cf57640af7..29fe2be6f533f 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -190,6 +190,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR21 | With a calendar other than `gregory` or `iso8601`, every presentation that would show the native control — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the popover on a fine pointer and the bottom sheet on a coarse pointer, because the native control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet shows months of the calendar: each scroller pane lays out one calendar month with calendar labels and day names and the locale's digits, as `component:Calendar` shows it, and every pane keeps the six-row height, including the five- or six-day thirteenth month of the Coptic and Ethiopic calendars. The month wheel lists the months of the selected calendar year, leap months by name; a year change keeps the month when the new year has it and otherwise moves to its common counterpart (Adar I becomes Adar); year labels carry the era where the calendar has one, such as `令和8`, and a year whose first and last days fall in different eras keeps one row, labeled with the era year of the date the wheels show: that year, the selected month, and the day the picker keeps, clamped to the month, else the month's first day. So 2019 reads `平成31` with January selected and `令和元` with July, and the row's text and accessible name change as the month wheel turns, while its size stays the same. The month and year heading takes its era from the same date when the shown month spans an era change, so January 1989 reads `昭和64年1月` for a kept day of 3; every other heading names the era of the month's middle day. A pane's grid name always takes the era of its month's middle day, so that pane reads `平成元年1月`. The closed sheet field writes the committed text in the calendar (`spec:AST-111` FR16). Values stay Gregorian ISO dates (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation, touch calendar, and no-`Temporal` tests | | FR22 | In the touch sheet, a month arrow at the range end is hidden; if it held focus, focus moves first to the other month arrow, or to the month-and-year button when both are hidden (`spec:AST-085/FR4`). | `spec:AST-085/FR4`; current source and sheet arrow tests | | FR23 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | +| FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the toggle's 0.25 s ease-in-ease-out. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | ### Allowed variation @@ -302,15 +303,15 @@ Consumer migration instructions belong in consumer docs and release notes. ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| -------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | ------------------------- | -| Label and supporting text | Shared Field hierarchy surrounds one coherent input boundary. | `family:input-fields` and `component:Field` | Supporting | FR10, AR1 | -| Field surface | One stable bordered input surface contains value and end controls. | `family:input-fields/FR1–FR2` | Prominent | FR1, FR7, FR10 | -| Calendar affordance | Communicates the picker path without replacing typed entry. | Current source and consumer docs | Supporting | FR8 | -| Calendar popover | Presents date selection while preserving Field ownership. | `component:Calendar`, `component:Popover` | Prominent | FR5, FR8–FR9 | -| Bottom-sheet calendar | Presents the same date contract for a coarse pointer. | Current source and `spec:AST-043`; weekday names: `component:Calendar` DD1 | Prominent | FR2, FR4, FR9, FR19, FR23 | -| Native picker | Hands picker chrome to the browser/OS while retaining DateInput field semantics. | `spec:AST-043`, browser/OS | Prominent | FR2–FR6, AR5 | -| Clear, busy, and status controls | Use the shared input-family end-control and status relationships. | `family:input-fields` and shared primitives | Supporting | FR7, FR11–FR12 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| -------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | ------------------------------- | +| Label and supporting text | Shared Field hierarchy surrounds one coherent input boundary. | `family:input-fields` and `component:Field` | Supporting | FR10, AR1 | +| Field surface | One stable bordered input surface contains value and end controls. | `family:input-fields/FR1–FR2` | Prominent | FR1, FR7, FR10 | +| Calendar affordance | Communicates the picker path without replacing typed entry. | Current source and consumer docs | Supporting | FR8 | +| Calendar popover | Presents date selection while preserving Field ownership. | `component:Calendar`, `component:Popover` | Prominent | FR5, FR8–FR9 | +| Bottom-sheet calendar | Presents the same date contract for a coarse pointer. | Current source and `spec:AST-043`; weekday names: `component:Calendar` DD1 | Prominent | FR2, FR4, FR9, FR19, FR23, FR24 | +| Native picker | Hands picker chrome to the browser/OS while retaining DateInput field semantics. | `spec:AST-043`, browser/OS | Prominent | FR2–FR6, AR5 | +| Clear, busy, and status controls | Use the shared input-family end-control and status relationships. | `family:input-fields` and shared primitives | Supporting | FR7, FR11–FR12 | ### Theming anatomy @@ -420,6 +421,7 @@ clear-glyph target of its own. | FR21 | `DateInput.calendarPresentation.test.tsx` (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"); `DateInputTouch.calendar.test.tsx` ("lays out a Persian month with its days, labels and locale digits", "shows Adar I and Adar II as separate months in a Hebrew leap year", "keeps six rows for the Ethiopic thirteenth month", "lists 13 months in a Hebrew leap year and 12 in a common year", "labels Japanese years with their era", and the month-model round trips); `calendarSystem.test.ts` `formatCalendarYearLabel` and `calendarYearSpansEras` cases (2019 in January and May, 1989 on January 3 and 22, 1926, 1912, and 2020); `DateInputTouch.calendar.test.tsx` "names the era of the shown month for a year with an era change" and the split-month heading cases (January 3, 1989; December 26, 1926); `calendarSystem.test.ts` `formatCalendarMonthLabel` kept-day cases; `DateInput.noTemporal.test.tsx`; real Chromium, Firefox, and WebKit bottom sheets and popovers with native `Temporal`, a polyfill, and no `Temporal` | Persian, Hebrew leap and common years, Ethiopic thirteenth month, Japanese eras, no `Temporal` | A native date control for a non-Gregorian calendar, a wheel with 12 months for a 13-month year, a pane of another height, a lost leap month, a missing era, a year with an era change labeled with an era other than that of the shown date, or a non-ISO value fails | `audit:DateInput/behavior` | | FR22 | `DateInputTouch.test.tsx` "moves focus to the other arrow when the focused arrow reaches the end of the range and hides" and "moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus" | Sheet month arrows at the first and last month of a range | Focus left on a hidden arrow, or dropped to the document body, fails. | `audit:DateInput/behavior` | | FR23 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | +| FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on UIKit’s 0.25 s ease-in-ease-out", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index ef92b139bd152..f07f524c22adc 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -145,6 +145,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR12 | With a calendar other than `gregory` or `iso8601`, every presentation that would show native controls — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the Astryx popovers on a fine pointer and the bottom sheet on a coarse pointer, for the date and the time alike, because the native date control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet's Date panel follows `component:DateInput` FR21: calendar months in six-row panes, the month wheel of the selected calendar year with leap months, the common counterpart on a year change, and year labels with their era; its date segment writes the date as `date_long` in the calendar (`spec:AST-111` FR16). The combined value stays a Gregorian ISO date-time (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation and touch calendar tests | | FR13 | While disabled or busy, the date segment keeps or drops typed text as `component:DateInput` FR12 states, judging it on the next Enter or blur once editable. | `spec:AST-085` FR2; current source and tests | | FR14 | Under a coarse primary pointer, the date sheet's month and year wheels MUST move a chosen date as `component:DateInput` FR23 does, keeping its time. | `component:DateInput` FR23; UIDatePicker on the iOS 26.2 simulator | +| FR15 | Under a coarse primary pointer, the date sheet's title chevron MUST point and turn as `component:DateInput` FR24 describes. When Done hides the month/year wheels while focus is inside them, focus MUST move to the shown month's tab stop once the calendar can take focus, not to a day of a month mounted off-screen. | `component:DateInput` FR24; the APG grid's single tab stop | ### Allowed variation @@ -316,6 +317,7 @@ leading clock glyph uses `date-time-input-clock-icon`. | FR12 | `DateInput.calendarPresentation.test.tsx` DateTimeInput cases (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"; "writes the sheet date segment in %s %s with its era" for `en` and `fa-IR` Persian and `th-TH` Buddhist); `DateInputTouch.calendar.test.tsx` "shows Persian months and the date in the calendar"; `DateInput.noTemporal.test.tsx` "shows a Persian DateTimeInput as Gregorian native controls" | Persian on fine and coarse pointers, explicit `native`, no `Temporal` | A native date or time control for a non-Gregorian calendar, a missing warning for an explicit `native`, or a Date panel in another calendar, fails | `audit:DateTimeInput/surfaces` | | FR13 | `DateTimeInput.test.tsx` "typed text while the field is disabled or busy" cases | Loading, disabled, and saves; unfinished, valid, reported, rejected, new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateTimeInput/state` | | FR14 | `DateTimeInputTouch.test.tsx` "under a finger: moves the chosen date with the month wheel, keeping the day and the time", "under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)", "under a finger: opening the month/year wheels on a date outside the range changes nothing", "under a finger: keeps a date a custom constraint refuses, while the calendar moves" | A month turn with a time; a pending Action; opened out of range; a refused date | A wheel that drops the time, leaves the date behind, changes the value on opening, or chooses a refused date fails. | `audit:DateTimeInput/surfaces` | +| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | | Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` | ## Current-state impact From 081c564c5362049fc63097fc19b3f2a7c069caab Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:40 -0800 Subject: [PATCH 18/40] test(DateInput): the toggle's storyboard holds the fade as it starts On a loaded runner the 0.25 s fade could end before the test's hold reached the page. The page now arms the hold before the tap (armHold), and the storyboard films what it held. --- .../MonthYearToggle.interaction.browser.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 4f27007fc3527..0ce0784169ea4 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -19,6 +19,7 @@ import { } from '@astryxdesign/a11y-spec/playwright'; import { animationStoryboard, + armHold, blockMainThread, busyWindowProof, freezeTimers, @@ -30,7 +31,6 @@ import { thawTimers, timeline, touchDriver, - waitForAnimation, waitForRest, watchAnimations, withStorybook, @@ -133,7 +133,7 @@ async function openCalendar( // Under a finger the hidden wheels warm up once the sheet has settled // (useWheelsWarmUp): their `visibility` turns visible at opacity 0, with its // own transition. Wait for its end, so a test's first tap meets a panel at - // rest. `blockMainThread` and `waitForAnimation` take any running animation + // rest. `blockMainThread` and `armHold` take any running animation // on the panel, and on a slow runner the warm-up can still be running. const wheels = page.locator('[data-panel="wheels"]').first(); await expect @@ -962,12 +962,16 @@ test( async ({page}, testInfo) => { const {driver, sheet, wheels, titleAt} = await openCalendar(page); await installTimerGate(page); + // The page holds the fade itself as soon as it runs. A hold sent after + // the tap can miss a 0.25 s fade on a loaded runner. + const hold = await armHold(page, sheet); await driver.tap(titleAt); - await waitForAnimation(wheels); + const held = await hold.held(); await freezeTimers(page); const {storyboard} = await animationStoryboard(page, { name: 'month-year-toggle', clip: sheet, + held, times: timeline(TOGGLE_MS, 1000 / 30), columns: 5, cellScale: 0.35, From f003f8a7c6ea188fa44431bebc3d34b0bb81c960 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:40 -0800 Subject: [PATCH 19/40] fix(DateTimeInput): with a mouse, Done hands focus to the month in view The mouse calendar gives every mounted month a tab stop, so Done focused the first mounted month's day, off-screen. The hand-off now takes the tab stop inside the panel's box. Under a finger the pager has one tab stop, so nothing changes there. The hand-off uses the shared focusable selector. --- .../calendar-toggle-uidatepicker-timing.md | 3 +- .../DateTimeInput/DateTimeInput.spec.md | 2 +- ...onthYearToggle.interaction.browser.spec.ts | 30 +++++++++++++++++++ packages/core/src/DateInput/useFadeInert.ts | 29 ++++++++++++++---- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index 1c9cb412a4925..c2ac434fc1d29 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -8,10 +8,11 @@ - The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A tapped day takes its selection at once, with no colour transition. -- When Done closes the wheels, the keyboard focus moves to the shown month's day. With a mouse, the toggle and the day's transition stay as they are. +In DateTimeInput, when Done closes the wheels with focus in them, the keyboard focus moves to the shown month's day, with any pointer. + With Reduce Motion on, nothing travels: under a finger the toggle cross-fades in 200 ms, and the chevron dips instead of turning, its turn landing mid-dip. With a mouse it changes at once. @vjeux diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index f07f524c22adc..51b2151978331 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -317,7 +317,7 @@ leading clock glyph uses `date-time-input-clock-icon`. | FR12 | `DateInput.calendarPresentation.test.tsx` DateTimeInput cases (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"; "writes the sheet date segment in %s %s with its era" for `en` and `fa-IR` Persian and `th-TH` Buddhist); `DateInputTouch.calendar.test.tsx` "shows Persian months and the date in the calendar"; `DateInput.noTemporal.test.tsx` "shows a Persian DateTimeInput as Gregorian native controls" | Persian on fine and coarse pointers, explicit `native`, no `Temporal` | A native date or time control for a non-Gregorian calendar, a missing warning for an explicit `native`, or a Date panel in another calendar, fails | `audit:DateTimeInput/surfaces` | | FR13 | `DateTimeInput.test.tsx` "typed text while the field is disabled or busy" cases | Loading, disabled, and saves; unfinished, valid, reported, rejected, new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateTimeInput/state` | | FR14 | `DateTimeInputTouch.test.tsx` "under a finger: moves the chosen date with the month wheel, keeping the day and the time", "under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)", "under a finger: opening the month/year wheels on a date outside the range changes nothing", "under a finger: keeps a date a custom constraint refuses, while the calendar moves" | A month turn with a time; a pending Action; opened out of range; a refused date | A wheel that drops the time, leaves the date behind, changes the value on opening, or chooses a refused date fails. | `audit:DateTimeInput/surfaces` | -| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | +| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus", "DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | | Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` | ## Current-state impact diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 0ce0784169ea4..403cde592ee8b 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -903,6 +903,36 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ test.describe('with a mouse', () => { test.use({hasTouch: false, isMobile: false}); + test('DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen', async ({ + page, + }) => { + await openStory(page, storybook(), DTI_STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page.locator('button[aria-label^="Open "]').first().click(); + await waitForRest( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + ); + await page + .locator('button[aria-label$="Choose month and year"]') + .first() + .click(); + const wheels = page.locator('[data-date-surface="wheels"]').first(); + await waitForRest(wheels); + // Done from the keyboard: focus sits in the wheels' footer. + const done = wheels.getByRole('button', {name: 'Done', exact: true}); + await done.focus(); + await page.keyboard.press('Enter'); + // The mouse calendar gives every mounted month a tab stop. The story's + // March 2026 is the month in view. + const day = page.locator( + 'button[data-date="2026-03-15"]:not([data-outside])', + ); + await expect(day).toBeFocused(); + await expect(day).toBeInViewport(); + }); + test('under Reduced Motion the toggle changes at once', async ({page}) => { // design:reduced-motion DR3: no animated property with a mouse. await page.emulateMedia({reducedMotion: 'reduce'}); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts index d1c2cd650c2da..5a5fe4d3dacda 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -15,14 +15,31 @@ */ import {useCallback, useEffect, useRef, useState, type RefObject} from 'react'; +import {FOCUSABLE_SELECTOR} from '../hooks/focusableSelector'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; -const FOCUSABLE = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - -/** A grid's one tab stop: in the calendar, the day of the shown month. */ +/** A month grid's tab stop: one day in each pane that has one. */ const TAB_STOP = '[tabindex="0"]'; +/** + * The tab stop of the month in view. The mouse calendar gives every mounted + * month a tab stop, and the months beside the shown one sit outside the + * panel's box. + */ +function shownTabStop(panel: HTMLElement): HTMLElement | null { + const box = panel.getBoundingClientRect(); + const stops = panel.querySelectorAll<HTMLElement>(TAB_STOP); + for (const stop of stops) { + const rect = stop.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + if (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom) { + return stop; + } + } + return stops[0] ?? null; +} + /** * Frames the hand-off tries for before it gives up: about half a second, * longer than the toggle's 0.25 s fade. @@ -185,8 +202,8 @@ export function useFadeInert( pendingFocusRef.current = null; const {incoming, from} = pending; const target = - incoming.querySelector<HTMLElement>(TAB_STOP) ?? - incoming.querySelector<HTMLElement>(FOCUSABLE) ?? + shownTabStop(incoming) ?? + incoming.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? incoming; cancelAnimationFrame(focusFrameRef.current); let frames = 0; From cd32c3036da6dcc15dcc63b7df4dc31a73536c47 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:41 -0800 Subject: [PATCH 20/40] fix(DateInput, DateTimeInput): the grid's tab stop records no focus, so Done's hand-off costs no render Done hands focus from the wheels to the shown month's tab stop. The day's focus handler recorded that day, so React rendered once more after the toggle's commit. The tab stop already names the day that a recorded focus would name. So while no day is recorded, its focus now records nothing. The test "Done hands focus from the wheels to the calendar in the toggle's commit" checks it. It fails at the parent (2 commits against 1) and passes here. Main's useTooltip change (#9756) removed an extra render that hid this one. --- packages/core/src/DateInput/MonthPane.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/DateInput/MonthPane.tsx b/packages/core/src/DateInput/MonthPane.tsx index 80afbb271c8f0..0dc20d7d977be 100644 --- a/packages/core/src/DateInput/MonthPane.tsx +++ b/packages/core/src/DateInput/MonthPane.tsx @@ -290,6 +290,7 @@ export const MonthPane = memo(function MonthPane({ !day.isOutside && today != null && plainDateIsEqual(day.date, today); + const isTabStop = hasTabStop && day.iso === tabbableISO; return ( <div @@ -301,7 +302,7 @@ export const MonthPane = memo(function MonthPane({ type="button" data-date={day.iso} data-outside={day.isOutside ? '' : undefined} - tabIndex={hasTabStop && day.iso === tabbableISO ? 0 : -1} + tabIndex={isTabStop ? 0 : -1} aria-label={formatCalendarDate( calendar, day.date, @@ -315,7 +316,13 @@ export const MonthPane = memo(function MonthPane({ onSelect(day.iso); } }} - onFocus={() => onDayFocus(day.date)} + onFocus={ + // The tab stop is already the day a recorded focus would + // name, so its focus records nothing and costs no render. + isTabStop && focusedDate == null + ? undefined + : () => onDayFocus(day.date) + } onKeyDown={event => onDayKeyDown(event, day.date)} {...stylex.props( styles.day, From 5400c34b86bf8637b3b8a18d49dd24b6eb5823e4 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:41 -0800 Subject: [PATCH 21/40] fix(DateInput, DateTimeInput): the month/year toggle plays the theme's tokens `spec:AST-094` FR45 has motion a tap starts play the theme duration token nearest its platform measurement in the Storybook theme, with the easing for its kind, and FR1 keeps measured values out of it. Under a finger the toggle's fades and the chevron's quarter turn played UIKit's 0.25 s ease-in-ease-out through the `timing.stateChangeMs` and `timing.curves.move` roles. The toggle now plays `--duration-medium-min`, 225 ms in the Storybook theme against the measured 250 ms. The wheels and the header's actions arrive on `--ease-entrance` and leave on `--ease-exit`: a transition takes the curve of the state it goes to, so the hidden styles carry the exit curve. The chevron's turn takes `--ease-move`. A unit test holds the token to the nearest of neutral's durations, and the browser specs read the tokens from the page. DateInput FR24, two browser test names and the changeset follow; Reduced Motion's cross-fade and the chevron's dip stay. --- .../calendar-toggle-uidatepicker-timing.md | 6 +- .../components/DateInput/DateInput.spec.md | 4 +- .../DateTimeInput/DateTimeInput.spec.md | 2 +- .../src/DateInput/DateInputTouch.test.tsx | 109 ++++++++---- .../core/src/DateInput/TouchDateField.tsx | 83 ++++++--- ...onthYearToggle.interaction.browser.spec.ts | 168 ++++++++++++++---- .../src/DateInput/monthYearToggle.stylex.ts | 16 +- packages/core/src/DateInput/useFadeInert.ts | 2 +- .../src/DateTimeInput/TouchDateTimeField.tsx | 49 +++-- 9 files changed, 307 insertions(+), 132 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index c2ac434fc1d29..3b98ae6a57f0f 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -2,11 +2,11 @@ '@astryxdesign/core': patch --- -[feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now follows the iOS date picker's timing: +[feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: -- The month/year toggle cross-fades in 0.25 s on iOS's ease-in-out curve, where DateInput used a quick linear fade and DateTimeInput an instant swap. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. +- The month/year toggle cross-fades over the theme duration token nearest iOS's 0.25 s (`--duration-medium-min`), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. - The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. -- The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. +- The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A tapped day takes its selection at once, with no colour transition. With a mouse, the toggle and the day's transition stay as they are. diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 29fe2be6f533f..ccaab22c3959e 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -190,7 +190,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR21 | With a calendar other than `gregory` or `iso8601`, every presentation that would show the native control — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the popover on a fine pointer and the bottom sheet on a coarse pointer, because the native control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet shows months of the calendar: each scroller pane lays out one calendar month with calendar labels and day names and the locale's digits, as `component:Calendar` shows it, and every pane keeps the six-row height, including the five- or six-day thirteenth month of the Coptic and Ethiopic calendars. The month wheel lists the months of the selected calendar year, leap months by name; a year change keeps the month when the new year has it and otherwise moves to its common counterpart (Adar I becomes Adar); year labels carry the era where the calendar has one, such as `令和8`, and a year whose first and last days fall in different eras keeps one row, labeled with the era year of the date the wheels show: that year, the selected month, and the day the picker keeps, clamped to the month, else the month's first day. So 2019 reads `平成31` with January selected and `令和元` with July, and the row's text and accessible name change as the month wheel turns, while its size stays the same. The month and year heading takes its era from the same date when the shown month spans an era change, so January 1989 reads `昭和64年1月` for a kept day of 3; every other heading names the era of the month's middle day. A pane's grid name always takes the era of its month's middle day, so that pane reads `平成元年1月`. The closed sheet field writes the committed text in the calendar (`spec:AST-111` FR16). Values stay Gregorian ISO dates (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation, touch calendar, and no-`Temporal` tests | | FR22 | In the touch sheet, a month arrow at the range end is hidden; if it held focus, focus moves first to the other month arrow, or to the month-and-year button when both are hidden (`spec:AST-085/FR4`). | `spec:AST-085/FR4`; current source and sheet arrow tests | | FR23 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | -| FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the toggle's 0.25 s ease-in-ease-out. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | +| FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the theme duration token nearest UIKit's measured 250 ms in the Storybook theme (`--duration-medium-min`, 225 ms there) and the move easing (`--ease-move`), as `spec:AST-094` FR45 asks. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | ### Allowed variation @@ -421,7 +421,7 @@ clear-glyph target of its own. | FR21 | `DateInput.calendarPresentation.test.tsx` (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"); `DateInputTouch.calendar.test.tsx` ("lays out a Persian month with its days, labels and locale digits", "shows Adar I and Adar II as separate months in a Hebrew leap year", "keeps six rows for the Ethiopic thirteenth month", "lists 13 months in a Hebrew leap year and 12 in a common year", "labels Japanese years with their era", and the month-model round trips); `calendarSystem.test.ts` `formatCalendarYearLabel` and `calendarYearSpansEras` cases (2019 in January and May, 1989 on January 3 and 22, 1926, 1912, and 2020); `DateInputTouch.calendar.test.tsx` "names the era of the shown month for a year with an era change" and the split-month heading cases (January 3, 1989; December 26, 1926); `calendarSystem.test.ts` `formatCalendarMonthLabel` kept-day cases; `DateInput.noTemporal.test.tsx`; real Chromium, Firefox, and WebKit bottom sheets and popovers with native `Temporal`, a polyfill, and no `Temporal` | Persian, Hebrew leap and common years, Ethiopic thirteenth month, Japanese eras, no `Temporal` | A native date control for a non-Gregorian calendar, a wheel with 12 months for a 13-month year, a pane of another height, a lost leap month, a missing era, a year with an era change labeled with an era other than that of the shown date, or a non-ISO value fails | `audit:DateInput/behavior` | | FR22 | `DateInputTouch.test.tsx` "moves focus to the other arrow when the focused arrow reaches the end of the range and hides" and "moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus" | Sheet month arrows at the first and last month of a range | Focus left on a hidden arrow, or dropped to the document body, fails. | `audit:DateInput/behavior` | | FR23 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | -| FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on UIKit’s 0.25 s ease-in-ease-out", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | +| FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on the theme’s tokens nearest UIKit’s toggle", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index 51b2151978331..bd44d4224a69e 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -317,7 +317,7 @@ leading clock glyph uses `date-time-input-clock-icon`. | FR12 | `DateInput.calendarPresentation.test.tsx` DateTimeInput cases (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"; "writes the sheet date segment in %s %s with its era" for `en` and `fa-IR` Persian and `th-TH` Buddhist); `DateInputTouch.calendar.test.tsx` "shows Persian months and the date in the calendar"; `DateInput.noTemporal.test.tsx` "shows a Persian DateTimeInput as Gregorian native controls" | Persian on fine and coarse pointers, explicit `native`, no `Temporal` | A native date or time control for a non-Gregorian calendar, a missing warning for an explicit `native`, or a Date panel in another calendar, fails | `audit:DateTimeInput/surfaces` | | FR13 | `DateTimeInput.test.tsx` "typed text while the field is disabled or busy" cases | Loading, disabled, and saves; unfinished, valid, reported, rejected, new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateTimeInput/state` | | FR14 | `DateTimeInputTouch.test.tsx` "under a finger: moves the chosen date with the month wheel, keeping the day and the time", "under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)", "under a finger: opening the month/year wheels on a date outside the range changes nothing", "under a finger: keeps a date a custom constraint refuses, while the calendar moves" | A month turn with a time; a pending Action; opened out of range; a refused date | A wheel that drops the time, leaves the date behind, changes the value on opening, or chooses a refused date fails. | `audit:DateTimeInput/surfaces` | -| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus", "DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | +| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on the theme’s motion tokens, the title chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus", "DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | | Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` | ## Current-state impact diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 98492046a95fb..435b3baee207d 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -60,7 +60,7 @@ import { } from './monthGeometry'; import {WHEEL_ROW_HEIGHT} from './wheelOptions'; import {datePicker} from '../motion/uikit/datePicker'; -import {timing} from '../motion/uikit/timing'; +import {expandMotionScale} from '../theme/expandMotionScale'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; @@ -3756,12 +3756,6 @@ describe('DateInput — scroll CSS (definition-level)', () => { * The title chevron is the exception and keeps the token: it rotates, and * rotation is travel. */ - /** - * UIDatePicker's month/year toggle: every fade and the chevron's quarter - * turn on `UIView.animate`'s default ease-in-ease-out, 0.25 s - * (motion/uikit/datePicker.ts `calendar.monthYearToggle`). StyleX inlines - * only literals, so the tokens repeat the table; they must stay it. - */ it('fades linearly, and eases only the thing that travels', () => { const source = read('TouchDateField.tsx'); const styles = source.slice( @@ -3773,33 +3767,49 @@ describe('DateInput — scroll CSS (definition-level)', () => { ); expect(chevron).toContain("transitionProperty: 'transform'"); expect(chevron).toContain("default: easeVars['--ease-standard']"); - // The chevron is the only eased transition; every fade is linear. + // The chevron is the only eased transition; every fade is linear, in its + // shown and its hidden style. expect( styles.match(/default: easeVars\['--ease-standard'\]/g), ).toHaveLength(1); - expect(styles.match(/default: 'linear'/g)).toHaveLength(4); + expect(styles.match(/default: 'linear'/g)).toHaveLength(8); }); /** - * Under a finger the swap is UIDatePicker's month/year toggle: every fade - * and the chevron's turn take 0.25 s on ease-in-ease-out. They read the - * motion roles' CSS variables (motion/uikit/datePicker.ts - * `calendar.monthYearToggle`). StyleX inlines only literals, so the tokens repeat the table - * as the variables' fallbacks; they must stay it. + * Under a finger the swap plays the theme duration token nearest + * UIDatePicker's measured toggle (motion/uikit/datePicker.ts + * `calendar.monthYearToggle`) in the Storybook theme, neutral. A part that + * arrives eases on the entrance curve, a part that leaves on the exit curve, + * and the chevron's turn on the move curve. Only the turn's angle comes from + * the table; StyleX inlines only literals, so the constant repeats it. */ - it('under a finger: fades and turns on UIDatePicker\u2019s toggle clock, from the table', () => { + it('under a finger: fades and turns on the theme\u2019s tokens nearest UIDatePicker\u2019s toggle', () => { const source = read('TouchDateField.tsx'); const styles = source.slice( source.indexOf('const styles = stylex.create('), ); - // The arrows, Reset, the weekday row and the layer above fade on the - // toggle's clock under a finger, in CSS: one touch key each. + // The arrows, Reset, the weekday row and the layer above fade in on the + // entrance curve. Their hidden styles carry the exit curve: one touch key + // each. expect( - styles.match(/'@media \(pointer: coarse\)': TOGGLE_CURVE/g), - ).toHaveLength(5); + styles.match(/'@media \(pointer: coarse\)': TOGGLE_ENTRANCE/g), + ).toHaveLength(4); + expect( + styles.match(/'@media \(pointer: coarse\)': TOGGLE_EXIT/g), + ).toHaveLength(4); + for (const hidden of [ + 'monthArrowsHidden', + 'headerResetHidden', + 'weekdaysHidden', + 'panelOverlayHidden', + ]) { + expect(declarations(source, hidden)).toContain( + "'@media (pointer: coarse)': TOGGLE_EXIT", + ); + } expect(styles).not.toContain("-webkit-touch-callout: none)': TOGGLE"); const chevron = declarations(source, 'titleChevron'); - expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_TURN"); // At rest it points along the reading direction (mirrored under RTL, // with the same function list in both states so the turn stays a turn). expect(chevron).toContain('default: `rotate(-${CHEVRON_TURN})`'); @@ -3809,27 +3819,51 @@ describe('DateInput — scroll CSS (definition-level)', () => { const open = declarations(source, 'titleChevronOpen'); expect(open).toContain("default: 'rotate(0deg)'"); expect(open).toContain("':is([dir=\"rtl\"] *)': 'scaleX(-1) rotate(0deg)'"); - // Not in tokens.stylex.ts: that module is a released subpath. - const tokens = read('monthYearToggle.stylex.ts'); - const toggle = datePicker.calendar.monthYearToggle; - // Prettier may break a long value onto its own line. - const flat = tokens.replace(/:\s+'/g, ": '"); - // Internal motion roles (spec:AST-094 FR4): the toggle's 0.25 s is - // timing.stateChangeMs, read through its variable. - expect(toggle.fadeMs).toBe(timing.stateChangeMs); - expect(flat).toContain( - `duration: 'var(--motion-timing-state-change-ms, ${timing.stateChangeMs}ms)'`, + expect(source).toContain( + "const TOGGLE_DURATION = durationVars['--duration-medium-min']", ); - expect(flat).toContain( - `curve: 'var(--motion-timing-curves-move, cubic-bezier(${timing.curves.move.join(', ')}))'`, + expect(source).toContain( + "const TOGGLE_ENTRANCE = easeVars['--ease-entrance']", ); + expect(source).toContain("const TOGGLE_EXIT = easeVars['--ease-exit']"); + expect(source).toContain("const TOGGLE_TURN = easeVars['--ease-move']"); + // In the Storybook theme, neutral, the duration token nearest the + // measured 250 ms is --duration-medium-min, at 225 ms. The scale is read + // from neutral's source, so a change to it is a change here. + const toggle = datePicker.calendar.monthYearToggle; + const neutralSource = readFileSync( + 'packages/themes/neutral/src/neutralTheme.ts', + 'utf8', + ); + const motion = /motion:\s*\{([^}]*)\}/.exec(neutralSource)?.[1] ?? ''; + const scale = Object.fromEntries( + motion.split(',').map(entry => { + const [name, value] = entry.split(':').map(part => part.trim()); + return [name, Number(value)]; + }), + ) as {fast: number; medium: number; slow: number; ratio: number}; + const steps = Object.entries(expandMotionScale(scale)) + .filter(([name]) => name.startsWith('--duration-')) + .map(([name, value]) => ({name, ms: Number.parseFloat(value)})); + const nearest = steps.reduce((best, step) => + Math.abs(step.ms - toggle.fadeMs) < Math.abs(best.ms - toggle.fadeMs) + ? step + : best, + ); + expect([toggle.fadeMs, nearest.name, nearest.ms]).toEqual([ + 250, + '--duration-medium-min', + 225, + ]); + // Not in tokens.stylex.ts: that module is a released subpath. + const tokens = read('monthYearToggle.stylex.ts'); expect(tokens).toContain(`chevronTurn: '${toggle.chevronTurnDegrees}deg'`); }); /** * Everything in the swap runs for the same time, so it reads as one change * rather than several, the chevron included: today's token with a mouse, - * UIKit's 0.25 s under a finger. + * the toggle's token under a finger. */ it('runs the whole swap on one duration', () => { const source = read('TouchDateField.tsx'); @@ -3847,7 +3881,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { "const SWAP_DURATION = durationVars['--duration-fast']", ); expect(source).toContain( - 'const TOGGLE_DURATION = monthYearToggleMotion.duration', + "const TOGGLE_DURATION = durationVars['--duration-medium-min']", ); }); @@ -3876,10 +3910,13 @@ describe('DateInput — scroll CSS (definition-level)', () => { "'@media (pointer: coarse)': 'opacity, visibility'", ); expect(lines).toContain("'@media (pointer: coarse)': TOGGLE_DURATION"); - expect(lines).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + expect(lines).toContain("'@media (pointer: coarse)': TOGGLE_ENTRANCE"); + expect(declarations(source, `${object}Hidden`)).toContain( + "'@media (pointer: coarse)': TOGGLE_EXIT", + ); } const chevron = declarations(source, 'touchTitleChevron'); - expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_CURVE"); + expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_TURN"); expect(declarations(source, 'touchTitleChevronOpen')).toContain( "default: 'rotate(0deg)'", ); diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 455ebb8096b47..567dea33d46ec 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -177,11 +177,16 @@ const TOUCH_TARGET = dateInputTouchSizes.daySize; */ const SWAP_DURATION = durationVars['--duration-fast']; /** - * UIDatePicker's month/year toggle under a finger: its fades and the chevron's - * quarter turn, 0.25 s on ease-in-ease-out (monthYearToggle.stylex.ts). + * The month/year toggle under a finger plays the theme duration token nearest + * UIDatePicker's measured 0.25 s in the Storybook theme, 225 ms there. A part + * that arrives eases on the entrance curve and a part that leaves on the exit + * curve. A transition takes the curve of the state it goes to, so the hidden + * styles carry the exit curve. The chevron's turn takes the move curve. */ -const TOGGLE_DURATION = monthYearToggleMotion.duration; -const TOGGLE_CURVE = monthYearToggleMotion.curve; +const TOGGLE_DURATION = durationVars['--duration-medium-min']; +const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; +const TOGGLE_EXIT = easeVars['--ease-exit']; +const TOGGLE_TURN = easeVars['--ease-move']; const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; const sizeStyles = stylex.create({ @@ -290,9 +295,9 @@ const styles = stylex.create({ // calendar the layer above cannot cover. They fade on its timing instead, // both directions, so the change reads as one motion rather than chrome // blinking out a beat before the grid is covered. - // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's - // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on - // a touch screen and immediate with a mouse. + // Under a finger the fade plays the toggle's tokens (TOGGLE_DURATION). + // Under Reduced Motion it is the policy's cross-fade on a touch screen and + // immediate with a mouse. transitionProperty: 'opacity, visibility', transitionDuration: { default: SWAP_DURATION, @@ -305,7 +310,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -333,6 +338,12 @@ const styles = stylex.create({ visibility: 'hidden', opacity: 0, pointerEvents: 'none', + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, /** * `Button`'s own sizes top out at 36px, which is fine for a mouse and short @@ -360,9 +371,9 @@ const styles = stylex.create({ headerReset: { display: 'flex', alignItems: 'center', - // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's - // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on - // a touch screen and immediate with a mouse. + // Under a finger the fade plays the toggle's tokens (TOGGLE_DURATION). + // Under Reduced Motion it is the policy's cross-fade on a touch screen and + // immediate with a mouse. transitionProperty: 'opacity, visibility', transitionDuration: { default: SWAP_DURATION, @@ -375,7 +386,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -384,6 +395,12 @@ const styles = stylex.create({ visibility: 'hidden', opacity: 0, pointerEvents: 'none', + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, /** * The same 44px floor the arrows take. `Button`'s `sm` is 32px, which is @@ -431,12 +448,12 @@ const styles = stylex.create({ titleChevron: { display: 'inline-flex', flexShrink: 0, - // The one part of the swap that keeps `--ease-standard`, because it is - // the one part that travels: a rotation has a distance to cover, and - // fast-out-slow-in is what that curve is for. Same duration as the - // surface it announces, so the two land together. Under a finger it is - // UIDatePicker's chevron. It points along the reading direction for the - // calendar and turns down on the toggle's own clock. + // With a mouse, it is the one part of the swap that keeps + // `--ease-standard`. It is the one part that travels: a rotation has a + // distance to cover. Same duration as the surface it announces, so the + // two land together. Under a finger it is UIDatePicker's chevron. It points along + // the reading direction for the calendar, and turns down on the toggle's + // duration and the move curve. transform: { default: 'none', '@media (pointer: coarse)': { @@ -459,7 +476,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: easeVars['--ease-standard'], - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_TURN, }, }, titleChevronOpen: { @@ -478,9 +495,9 @@ const styles = stylex.create({ alignItems: 'center', // Same as the arrows: above the plate's reach, so it fades on the layer's // timing rather than clearing on its own. - // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's - // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on - // a touch screen and immediate with a mouse. + // Under a finger the fade plays the toggle's tokens (TOGGLE_DURATION). + // Under Reduced Motion it is the policy's cross-fade on a touch screen and + // immediate with a mouse. transitionProperty: 'opacity, visibility', transitionDuration: { default: SWAP_DURATION, @@ -493,7 +510,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -503,6 +520,12 @@ const styles = stylex.create({ // opening the wheels would make the picker shorter. visibility: 'hidden', opacity: 0, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, weekday: { textAlign: 'center', @@ -614,9 +637,9 @@ const styles = stylex.create({ panelOverlay: { backgroundColor: colorVars['--color-background-surface'], isolation: 'isolate', - // Under a finger the fade is UIDatePicker's: 0.25 s on Core Animation's - // ease-in-ease-out. Under Reduced Motion it is the policy's cross-fade on - // a touch screen and immediate with a mouse. + // Under a finger the fade plays the toggle's tokens (TOGGLE_DURATION). + // Under Reduced Motion it is the policy's cross-fade on a touch screen and + // immediate with a mouse. transitionProperty: 'opacity, visibility', transitionDuration: { default: SWAP_DURATION, @@ -629,7 +652,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -638,6 +661,12 @@ const styles = stylex.create({ visibility: 'hidden', opacity: 0, pointerEvents: 'none', + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, // The hidden wheels under a finger once the sheet has settled: laid out, at // opacity 0, so the toggle's commit only changes their opacity diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 403cde592ee8b..10975fa354b22 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -5,7 +5,7 @@ * @input The built "Bottom Sheet Calendar" DateInput story, real touch input * and the interaction harness * @output Interaction proofs that the calendar's month/year toggle runs on - * UIDatePicker's clock under a finger, in Chromium and WebKit: the fades, + * the theme's tokens under a finger, in Chromium and WebKit: the fades, * the chevron, a turnaround, a blocked main thread, Reduced Motion and RTL * @position Interaction tests of TouchDateField.tsx, TouchDateTimeField.tsx and * MonthPager.tsx (`*.interaction.browser.spec.ts`) @@ -40,13 +40,9 @@ import { type TrackedFrame, } from '@astryxdesign/interaction-harness'; import {SHEET_SETTLE_MS} from '../useWheelsWarmUp'; -import {datePicker} from '../../motion/uikit/datePicker'; -import {timing} from '../../motion/uikit/timing'; const storybook = withStorybook(test); -const TOGGLE_MS = datePicker.calendar.monthYearToggle.fadeMs; - /** CSS's named cubic-bézier easings, which a browser may report by name. */ const NAMED_EASINGS: Record<string, ReadonlyArray<number>> = { ease: [0.25, 0.1, 0.25, 1], @@ -72,25 +68,79 @@ function bezierOf(easing: string): ReadonlyArray<number> { return match[1].split(',').map(Number); } +/** The toggle's tokens, as the page's theme resolves them. */ +interface ToggleTokens { + /** `--duration-medium-min`, in ms. */ + ms: number; + /** The control points of `--ease-entrance`, `--ease-exit` and `--ease-move`. */ + entrance: ReadonlyArray<number>; + exit: ReadonlyArray<number>; + move: ReadonlyArray<number>; +} + +/** + * Reads the toggle's tokens from the sheet. A browser spec cannot import + * tokens.stylex.ts, which needs the StyleX compiler, and a theme's tempo and + * character set the values. + */ +async function toggleTokens(page: Page): Promise<ToggleTokens> { + const [duration, entrance, exit, move] = await page + .locator('.astryx-bottom-sheet') + .first() + .evaluate(element => { + const style = getComputedStyle(element); + return [ + '--duration-medium-min', + '--ease-entrance', + '--ease-exit', + '--ease-move', + ].map(name => style.getPropertyValue(name).trim()); + }); + const ms = Number.parseFloat(duration); + expect(ms, duration).toBeGreaterThan(0); + return { + ms: duration.endsWith('ms') ? ms : ms * 1000, + entrance: bezierOf(entrance), + exit: bezierOf(exit), + move: bezierOf(move), + }; +} + +/** The steepest slope of a cubic-bézier easing, in output per unit of time. */ +function steepestSlope([x1, y1, x2, y2]: ReadonlyArray<number>): number { + const at = (t: number, a: number, b: number) => + 3 * a * (1 - t) ** 2 * t + 3 * b * (1 - t) * t ** 2 + t ** 3; + let steepest = 0; + for (let i = 0; i < 1000; i += 1) { + const [t0, t1] = [i / 1000, (i + 1) / 1000]; + const dx = at(t1, x1, x2) - at(t0, x1, x2); + if (dx > 0) { + steepest = Math.max(steepest, (at(t1, y1, y2) - at(t0, y1, y2)) / dx); + } + } + return steepest; +} + /** - * The toggle's run of `property` as the page plays it: UIKit's 0.25 s - * ease-in-ease-out from one end value to the other. It is read from the - * watched transition itself, whose duration, easing and end values fix every - * frame. A hold taken after the tap returns can miss the fade on a loaded - * runner. + * The toggle's run of `property` as the page plays it: `ease` over the + * toggle's duration token, from one end value to the other. It is read from + * the watched transition itself, whose duration, easing and end values fix + * every frame. A hold taken after the tap returns can miss the fade on a + * loaded runner. */ function expectToggleRun( runs: ReadonlyArray<ActiveAnimation>, property: string, [from, to]: readonly [string, string], + {ms, ease}: {ms: number; ease: ReadonlyArray<number>}, ) { const run = runs.find(candidate => candidate.name === property); if (run == null) { throw new Error(`no ${property} run among ${JSON.stringify(runs)}`); } - expect(run.durationMs).toBe(TOGGLE_MS); + expect(run.durationMs).toBe(ms); const points = bezierOf(run.easing); - timing.curves.move.forEach((point, index) => + ease.forEach((point, index) => expect(points[index], run.easing).toBeCloseTo(point, 3), ); const frames = run.keyframes; @@ -190,24 +240,53 @@ async function runningAnimations(page: Page, selector: string) { ); } -test('the wheels fade in and the chevron turns on UIKit’s 0.25 s ease-in-ease-out', async ({ +test('the wheels fade in and the chevron turns on the theme’s tokens nearest UIKit’s toggle', async ({ page, }) => { const {driver, wheels, titleAt} = await openCalendar(page); + const tokens = await toggleTokens(page); const chevron = page .locator('[data-title="month-year"] .astryx-icon') .first(); const fade = await watchAnimations(wheels); const turn = await watchAnimations(chevron); await driver.tap(titleAt); - expectToggleRun(await fade.started(), 'opacity', ['0', '1']); - expectToggleRun(await turn.started(), 'transform', [ - 'rotate(-90deg)', - 'rotate(0deg)', - ]); + expectToggleRun(await fade.started(), 'opacity', ['0', '1'], { + ms: tokens.ms, + ease: tokens.entrance, + }); + expectToggleRun( + await turn.started(), + 'transform', + ['rotate(-90deg)', 'rotate(0deg)'], + {ms: tokens.ms, ease: tokens.move}, + ); }); -test('DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s ease-in-ease-out, the chevron turning with them', async ({ +test('the wheels fade out on the exit curve when they close', async ({ + page, +}) => { + const {driver, wheels, titleAt} = await openCalendar(page); + const tokens = await toggleTokens(page); + await driver.tap(titleAt); + // The opening fade's own end, so the closing run starts from full opacity. + await waitForOpacityAbove(wheels, 0.99); + await wheels.evaluate(async element => { + await Promise.all( + element + .getAnimations() + .map(async animation => animation.finished.catch(() => undefined)), + ); + }); + const fade = await watchAnimations(wheels); + await driver.tap(titleAt); + expectToggleRun(await fade.started(), 'opacity', ['1', '0'], { + ms: tokens.ms, + ease: tokens.exit, + }); +}); + +test('DateTimeInput: the calendar and the wheels cross-fade on the theme’s motion tokens, the title chevron turning with them', async ({ page, }) => { await openStory(page, storybook(), DTI_STORY); @@ -225,26 +304,33 @@ test('DateTimeInput: the calendar and the wheels cross-fade on UIKit’s 0.25 s await waitForRest( page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), ); + const tokens = await toggleTokens(page); const chevron = page .locator('button[aria-label$="Choose month and year"] .astryx-icon') .first(); const fade = await watchAnimations(wheels); const turn = await watchAnimations(chevron); await tapCenter('button[aria-label$="Choose month and year"]'); - expectToggleRun(await fade.started(), 'opacity', ['0', '1']); - expectToggleRun(await turn.started(), 'transform', [ - 'rotate(-90deg)', - 'rotate(0deg)', - ]); + expectToggleRun(await fade.started(), 'opacity', ['0', '1'], { + ms: tokens.ms, + ease: tokens.entrance, + }); + expectToggleRun( + await turn.started(), + 'transform', + ['rotate(-90deg)', 'rotate(0deg)'], + {ms: tokens.ms, ease: tokens.move}, + ); }); test('a second tap mid-fade turns the fade around from where it is', async ({ page, }) => { const {driver, wheels, titleAt} = await openCalendar(page); + const tokens = await toggleTokens(page); // A test stylesheet stretches the toggle's fades and the chevron's turn to // 2 s. So the second tap lands mid-fade every time, instead of racing a - // 250 ms fade. The turnaround does not depend on the length. + // toggle's fade. The turnaround does not depend on the length. await page.addStyleTag({ content: '[data-panel], button[aria-label$="Choose month and year"] .astryx-icon { transition-duration: 2000ms !important; }', @@ -297,13 +383,19 @@ test('a second tap mid-fade turns the fade around from where it is', async ({ // The first fade never finished: the second tap caught it part way in. expect(peak).toBeGreaterThan(0.3); expect(peak).toBeLessThan(0.95); - // From there it only falls back to 0: never up to 1, never a jump. The - // fall is bounded per millisecond, so a dropped frame is not a jump. + // From there it only falls back to 0: never up to 1, never a jump. The fall + // is a leave, on the exit curve the hidden style carries. Each step falls at + // most what that curve's steepest slope covers in the step's own time on + // the 2 s run. That time counts as at least 50 ms. So a late frame is not a + // jump, and a jump still fails. + const steepest = steepestSlope(tokens.exit); for (let i = peakAt + 1; i < fade.length; i += 1) { const [time, opacity] = fade[i]; const [lastTime, last] = fade[i - 1]; expect(opacity).toBeLessThanOrEqual(last + 1e-6); - expect((last - opacity) / Math.max(1, time - lastTime)).toBeLessThan(0.004); + expect(last - opacity).toBeLessThan( + (1.5 * steepest * Math.max(50, time - lastTime)) / 2000, + ); } expect(fade.at(-1)?.[1]).toBe(0); }); @@ -477,9 +569,10 @@ test('the wheels keep fading in every frame through a blocked main thread', asyn }, testInfo) => { test.skip( browserName === 'webkit', - "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so a 0.25 s fade cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", + "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so the toggle's fade cannot be placed inside the block; Mobile Safari on the iOS simulator is this proof's WebKit evidence", ); const {driver, wheels, titleAt} = await openCalendar(page); + const tokens = await toggleTokens(page); // A magenta chip on the wheels' panel: its colour on screen is the fade. await markElement(wheels, {inset: 8, size: 24}); const chip = page.locator('[data-harness-marker-chip]').first(); @@ -516,8 +609,8 @@ test('the wheels keep fading in every frame through a blocked main thread', asyn pixels: 1, })); const started = busy.from - 32; - const spanned = {...busy, to: Math.min(busy.to, started + TOGGLE_MS)}; - expect(spanned.to - spanned.from).toBeGreaterThan(150); + const spanned = {...busy, to: Math.min(busy.to, started + tokens.ms)}; + expect(spanned.to - spanned.from).toBeGreaterThan(0.6 * tokens.ms); const proof = busyWindowProof(tracked, spanned, { axis: 'x', settledWithin: 10, @@ -625,6 +718,7 @@ test('in RTL the chevron points left, and turns to point down', async ({ page, }) => { const {driver, wheels, titleAt} = await openCalendar(page, {rtl: true}); + const tokens = await toggleTokens(page); // Where the chevron's glyph (drawn pointing down) points on screen: its // transform (the turn, mirrored under RTL) applied to +y. const pointing = async () => @@ -645,7 +739,7 @@ test('in RTL the chevron points left, and turns to point down', async ({ expect(await pointing()).toEqual({x: -1, y: 0}); await driver.tap(titleAt); await waitForRest(wheels); - await page.waitForTimeout(TOGGLE_MS + 100); + await page.waitForTimeout(tokens.ms + 100); expect(await pointing()).toEqual({x: 0, y: 1}); }); @@ -770,6 +864,7 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ timeout: SHEET_SETTLE_MS + 2000, }) .toBe('visible'); + const tokens = await toggleTokens(page); await page.evaluate( ({selector, key}) => { const state = { @@ -876,7 +971,7 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ expect( (live?.time ?? Number.NaN) - commitTime, JSON.stringify(log), - ).toBeGreaterThanOrEqual(TOGGLE_MS); + ).toBeGreaterThanOrEqual(tokens.ms); expect(live?.wheelsOpacity, JSON.stringify(log)).toBe(1); // The outgoing calendar turns inert once the cross-fade has ended: faded // to 0, hidden, or covered by the wheels at opacity 1. Never part way. @@ -896,7 +991,7 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ expect( (inert?.time ?? Number.NaN) - commitTime, JSON.stringify(log), - ).toBeGreaterThanOrEqual(TOGGLE_MS); + ).toBeGreaterThanOrEqual(tokens.ms); }); } @@ -991,9 +1086,10 @@ test( {tag: '@storyboard'}, async ({page}, testInfo) => { const {driver, sheet, wheels, titleAt} = await openCalendar(page); + const tokens = await toggleTokens(page); await installTimerGate(page); // The page holds the fade itself as soon as it runs. A hold sent after - // the tap can miss a 0.25 s fade on a loaded runner. + // the tap can miss the toggle's fade on a loaded runner. const hold = await armHold(page, sheet); await driver.tap(titleAt); const held = await hold.held(); @@ -1002,7 +1098,7 @@ test( name: 'month-year-toggle', clip: sheet, held, - times: timeline(TOGGLE_MS, 1000 / 30), + times: timeline(tokens.ms, 1000 / 30), columns: 5, cellScale: 0.35, measure: async () => ({ diff --git a/packages/core/src/DateInput/monthYearToggle.stylex.ts b/packages/core/src/DateInput/monthYearToggle.stylex.ts index 8af071512d785..a4a41de7b834e 100644 --- a/packages/core/src/DateInput/monthYearToggle.stylex.ts +++ b/packages/core/src/DateInput/monthYearToggle.stylex.ts @@ -3,8 +3,8 @@ /** * @file monthYearToggle.stylex.ts * @input StyleX constants - * @output Exports monthYearToggleMotion, UIDatePicker's month/year toggle - * timing for the calendar under a finger + * @output Exports monthYearToggleMotion, the chevron turn of UIDatePicker's + * month/year toggle for the calendar under a finger * @position Internal constants consumed by TouchDateField.tsx and * TouchDateTimeField.tsx; not a package export (the released DateInput * tokens live in tokens.stylex.ts) @@ -17,16 +17,12 @@ import * as stylex from '@stylexjs/stylex'; /** * UIDatePicker's month/year toggle under a finger, from the UIKit constants - * table (motion/uikit/datePicker.ts `calendar.monthYearToggle`). The duration - * and curve are the `timing.stateChangeMs` and `timing.curves.move` motion - * roles, read as CSS variables with their table values as fallbacks. StyleX - * inlines only literal constants, so a test holds these to the table. + * table (motion/uikit/datePicker.ts `calendar.monthYearToggle`). Its fades + * and turn play the theme's tokens; only the turn's angle comes from the + * table. StyleX inlines only literal constants, so a test holds it to the + * table. */ export const monthYearToggleMotion = stylex.defineConsts({ - /** Every fade of the toggle and the chevron's turn: timing.stateChangeMs. */ - duration: 'var(--motion-timing-state-change-ms, 250ms)', - /** `UIView.animate`'s default curve: timing.curves.move. */ - curve: 'var(--motion-timing-curves-move, cubic-bezier(0.42, 0, 0.58, 1))', /** The title chevron's quarter turn: chevronTurnDegrees. */ chevronTurn: '90deg', }); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts index 5a5fe4d3dacda..9fd2ccf8783c0 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -42,7 +42,7 @@ function shownTabStop(panel: HTMLElement): HTMLElement | null { /** * Frames the hand-off tries for before it gives up: about half a second, - * longer than the toggle's 0.25 s fade. + * longer than the toggle's fade. */ const FOCUS_FRAMES = 30; diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index cc92119d65451..cd14d18248236 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -60,6 +60,8 @@ import {useTooltip} from '../Tooltip'; import {useFieldInputStyling} from '../Field/fieldParts'; import { colorVars, + durationVars, + easeVars, fontWeightVars, radiusVars, sizeVars, @@ -196,12 +198,15 @@ const sizeStyles = stylex.create({ const HORIZONTAL_SEGMENT_BASIS = 196; /** - * UIDatePicker's month/year toggle under a finger: its fades and the chevron's - * quarter turn, 0.25 s on ease-in-ease-out (DateInput's - * monthYearToggle.stylex.ts). + * The month/year toggle under a finger, as in DateInput: the theme duration + * token nearest UIDatePicker's measured 0.25 s in the Storybook theme. A part + * that arrives eases on the entrance curve, and the hidden styles carry the + * exit curve. The chevron's turn takes the move curve. */ -const TOGGLE_DURATION = monthYearToggleMotion.duration; -const TOGGLE_CURVE = monthYearToggleMotion.curve; +const TOGGLE_DURATION = durationVars['--duration-medium-min']; +const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; +const TOGGLE_EXIT = easeVars['--ease-exit']; +const TOGGLE_TURN = easeVars['--ease-move']; const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; const styles = stylex.create({ @@ -345,10 +350,10 @@ const styles = stylex.create({ alignItems: 'center', gap: spacingVars['--spacing-0-5'], marginInlineStart: 'auto', - // Under a finger the month/year toggle is UIDatePicker's cross-fade: - // 0.25 s on Core Animation's ease-in-ease-out. With a mouse the swap stays - // instant. Under Reduced Motion a touch screen gets the policy's - // cross-fade and a mouse the instant swap. + // Under a finger the month/year toggle cross-fades on its tokens + // (TOGGLE_DURATION). With a mouse the swap stays instant. Under Reduced + // Motion a touch screen gets the policy's cross-fade and a mouse the + // instant swap. transitionProperty: { default: 'none', '@media (pointer: coarse)': 'opacity, visibility', @@ -365,7 +370,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -376,6 +381,12 @@ const styles = stylex.create({ touchHeaderActionsHidden: { visibility: 'hidden', opacity: 0, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, touchArrow: { minBlockSize: { @@ -426,10 +437,10 @@ const styles = stylex.create({ minInlineSize: 0, flexDirection: 'column', gap: spacingVars['--spacing-2'], - // Under a finger the month/year toggle is UIDatePicker's cross-fade: - // 0.25 s on Core Animation's ease-in-ease-out. With a mouse the swap stays - // instant. Under Reduced Motion a touch screen gets the policy's - // cross-fade and a mouse the instant swap. + // Under a finger the month/year toggle cross-fades on its tokens + // (TOGGLE_DURATION). With a mouse the swap stays instant. Under Reduced + // Motion a touch screen gets the policy's cross-fade and a mouse the + // instant swap. transitionProperty: { default: 'none', '@media (pointer: coarse)': 'opacity, visibility', @@ -446,7 +457,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'linear', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_ENTRANCE, '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': reducedMotionFadeConsts.easing, }, @@ -454,6 +465,12 @@ const styles = stylex.create({ touchDateSurfaceHidden: { visibility: 'hidden', opacity: 0, + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, // The hidden wheels under a finger once the sheet has settled: laid out, at // opacity 0, so the toggle's commit only changes their opacity @@ -490,7 +507,7 @@ const styles = stylex.create({ }, transitionTimingFunction: { default: 'ease', - '@media (pointer: coarse)': TOGGLE_CURVE, + '@media (pointer: coarse)': TOGGLE_TURN, }, }, touchTitleChevronOpen: { From a15489a19e1be62cce74852f667e5d514011248d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:41 -0800 Subject: [PATCH 22/40] fix(DateInput, DateTimeInput): only a transition the toggle starts ends its cross-fade The panels swap `inert` when the cross-fade ends, on either panel's own transition end. Once the sheet settles, the wheels' warm-up turns their `visibility` on, on the toggle's duration. A toggle soon after it saw that transition end mid-fade: the shield came off and the panels swapped `inert` while they still faded. The hook now counts only the transitions that run after the toggle (`transitionrun`), and their ends end the fade. A cancel whose property runs again at once is a replacement, not an end, so the replacement's own end ends the fade. --- .../src/DateInput/DateInputTouch.test.tsx | 13 +++-- .../core/src/DateInput/useFadeInert.test.tsx | 52 +++++++++++++++++++ packages/core/src/DateInput/useFadeInert.ts | 50 ++++++++++++++++-- .../DateTimeInput/DateTimeInputTouch.test.tsx | 13 +++-- 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 435b3baee207d..0f82cf37af343 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -2394,11 +2394,14 @@ describe('DateInput — month/year wheels', () => { button('Done'), ].map(isBlocked); const fadeEnd = async (which: 'calendar' | 'wheels') => { - const event = new Event('transitionend', {bubbles: true}); - Object.assign(event, {propertyName: 'opacity'}); - act(() => { - panel(which).dispatchEvent(event); - }); + // The toggle's own fade: it runs, then ends. + for (const type of ['transitionrun', 'transitionend']) { + const event = new Event(type, {bubbles: true}); + Object.assign(event, {propertyName: 'opacity'}); + act(() => { + panel(which).dispatchEvent(event); + }); + } await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); }); diff --git a/packages/core/src/DateInput/useFadeInert.test.tsx b/packages/core/src/DateInput/useFadeInert.test.tsx index 6a399aa67e5e3..4c714250dc42d 100644 --- a/packages/core/src/DateInput/useFadeInert.test.tsx +++ b/packages/core/src/DateInput/useFadeInert.test.tsx @@ -81,7 +81,17 @@ function fading(...elements: HTMLElement[]) { } } +/** A transition of `propertyName` starting on `element`, as the toggle's fade does. */ +function transitionRun(element: HTMLElement, propertyName = 'opacity') { + const event = new Event('transitionrun', {bubbles: true}); + Object.assign(event, {propertyName}); + act(() => { + element.dispatchEvent(event); + }); +} + async function fadeEnd(element: HTMLElement, type = 'transitionend') { + transitionRun(element); const event = new Event(type, {bubbles: true}); Object.assign(event, {propertyName: 'opacity'}); act(() => { @@ -118,6 +128,29 @@ describe('useFadeInert', () => { expect(wheels.hasAttribute('inert')).toBe(false); }); + it('does not settle at the end of a transition that ran before the toggle', async () => { + const view = render(<Panels isWheelOpen={false} />); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(<Panels isWheelOpen />); + // The wheels' warm-up turned their visibility on just before the toggle: + // that transition ends during the fade, and the panels keep their inert. + const warmUpEnd = new Event('transitionend', {bubbles: true}); + Object.assign(warmUpEnd, {propertyName: 'visibility'}); + act(() => { + wheels.dispatchEvent(warmUpEnd); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(calendar.hasAttribute('inert')).toBe(false); + expect(wheels.hasAttribute('inert')).toBe(true); + await fadeEnd(calendar); + expect(calendar.hasAttribute('inert')).toBe(true); + expect(wheels.hasAttribute('inert')).toBe(false); + }); + it('waits for the visibility the layer beneath holds for the fade', async () => { const view = render(<Panels isWheelOpen={false} />); const calendar = view.getByTestId('calendar'); @@ -127,6 +160,7 @@ describe('useFadeInert', () => { ] as unknown as Animation[]; view.rerender(<Panels isWheelOpen />); expect(calendar.hasAttribute('inert')).toBe(false); + transitionRun(calendar, 'visibility'); const end = new Event('transitionend', {bubbles: true}); Object.assign(end, {propertyName: 'visibility'}); act(() => { @@ -160,11 +194,29 @@ describe('useFadeInert', () => { view.rerender(<Panels isWheelOpen={false} />); expect(wheels.hasAttribute('inert')).toBe(false); expect(calendar.hasAttribute('inert')).toBe(true); + // The cancelled transition is gone, and nothing replaces it. + wheels.getAnimations = () => []; await fadeEnd(wheels, 'transitioncancel'); expect(wheels.hasAttribute('inert')).toBe(true); expect(calendar.hasAttribute('inert')).toBe(false); }); + it('waits for the replacement when a cancelled transition runs again at once', async () => { + const view = render(<Panels isWheelOpen />); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(<Panels isWheelOpen={false} />); + // The wheels' opacity transition is cancelled and replaced in one frame: + // a transition of that property still runs, so the fade goes on. + await fadeEnd(wheels, 'transitioncancel'); + expect(wheels.hasAttribute('inert')).toBe(false); + expect(calendar.hasAttribute('inert')).toBe(true); + await fadeEnd(wheels); + expect(wheels.hasAttribute('inert')).toBe(true); + expect(calendar.hasAttribute('inert')).toBe(false); + }); + it('swaps inert with the toggle with a mouse, fade or not', () => { const view = render(<Panels isWheelOpen={false} isDeferred={false} />); fading(view.getByTestId('calendar'), view.getByTestId('wheels')); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useFadeInert.ts index 9fd2ccf8783c0..8a70d274145cd 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useFadeInert.ts @@ -52,6 +52,21 @@ const FOCUS_FRAMES = 30; */ const EXIT_PROPERTIES = new Set(['opacity', 'visibility']); +/** Whether a transition of `property` runs on the element itself. */ +function isRunning(element: Element, property: string): boolean { + if (typeof element.getAnimations !== 'function') { + return false; + } + return element + .getAnimations() + .some( + animation => + animation.playState === 'running' && + (animation as {transitionProperty?: string}).transitionProperty === + property, + ); +} + /** Whether the panel's own exit transition is running. */ function isExiting(element: Element): boolean { if (typeof element.getAnimations !== 'function') { @@ -131,16 +146,40 @@ export function useFadeInert( ); }; // The cross-fade ends when either panel's own transition ends. The layer - // beneath may have none: it stays visible, covered. + // beneath may have none: it stays visible, covered. Only a transition + // that runs after the toggle counts. One already running ends on its own + // clock. The wheels' warm-up turns their `visibility` on, and a toggle + // soon after would otherwise settle at its end. + const started = new Set<string>(); + const keyOf = (event: Event) => { + const index = panels.indexOf(event.target as HTMLElement); + const property = (event as TransitionEvent).propertyName; + return index >= 0 && EXIT_PROPERTIES.has(property) + ? `${index}:${property}` + : null; + }; + const onRun = (event: Event) => { + const key = keyOf(event); + if (key != null) { + started.add(key); + } + }; + // A cancel whose property runs again at once is a replacement, not an + // end: the replacement's own end ends the fade. + const isReplaced = (event: Event) => + event.type === 'transitioncancel' && + isRunning( + event.target as Element, + (event as TransitionEvent).propertyName, + ); const onEnd = (event: Event) => { - if ( - panels.includes(event.target as HTMLElement) && - EXIT_PROPERTIES.has((event as TransitionEvent).propertyName) - ) { + const key = keyOf(event); + if (key != null && started.has(key) && !isReplaced(event)) { settle(); } }; for (const panel of panels) { + panel.addEventListener('transitionrun', onRun); panel.addEventListener('transitionend', onEnd); panel.addEventListener('transitioncancel', onEnd); } @@ -155,6 +194,7 @@ export function useFadeInert( return () => { cancelAnimationFrame(frame); for (const panel of panels) { + panel.removeEventListener('transitionrun', onRun); panel.removeEventListener('transitionend', onEnd); panel.removeEventListener('transitioncancel', onEnd); } diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index db48f6c538a88..d76ebb101e68f 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -919,11 +919,14 @@ describe('DateTimeInput touch surface', () => { throw new Error('no month/year title'); } const fadeEnd = async (which: 'calendar' | 'wheels') => { - const event = new Event('transitionend', {bubbles: true}); - Object.assign(event, {propertyName: 'opacity'}); - act(() => { - surface(which).dispatchEvent(event); - }); + // The toggle's own fade: it runs, then ends. + for (const type of ['transitionrun', 'transitionend']) { + const event = new Event(type, {bubbles: true}); + Object.assign(event, {propertyName: 'opacity'}); + act(() => { + surface(which).dispatchEvent(event); + }); + } // The swap lands one frame after the end. await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); From a9368ecc737bfbab388367e4bc95d450eb82e336 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:41 -0800 Subject: [PATCH 23/40] test(DateInput): the toggle spec waits for the sheet and the wheels' warm-up to end The toggle spec's opening waited for the sheet and the warmed-up wheels with `waitForRest`, which needs three still frames within 5 s. A starved runner can present one frame in seconds, so the wait failed with "still moving" while nothing moved. It now waits for each one's resting value, checked once a frame with no deadline of its own, and then for each animation's own `finished` promise, as the month and year wheels' spec does. --- ...onthYearToggle.interaction.browser.spec.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 10975fa354b22..37c1123cbe984 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -151,6 +151,43 @@ function expectToggleRun( const STORY = 'core-dateinput--bottom-sheet-calendar'; const DTI_STORY = 'core-datetimeinput--bottom-sheet-calendar'; +/** + * Resolves once `locator`'s element shows `property` at `value` and no + * animation inside it is left unfinished. The page checks the value once a + * frame, then awaits each animation's own `finished` promise. It sets no + * deadline of its own, so a starved runner only makes it later. + */ +async function settlesAt(locator: Locator, property: string, value: string) { + await locator.evaluate( + async (element, [name, expected]) => { + await new Promise<void>(resolve => { + const check = () => { + if (getComputedStyle(element).getPropertyValue(name) === expected) { + resolve(); + } else { + requestAnimationFrame(check); + } + }; + check(); + }); + for (;;) { + const unfinished = element + .getAnimations({subtree: true}) + .filter(animation => animation.playState !== 'finished'); + if (unfinished.length === 0) { + return; + } + await Promise.all( + unfinished.map(async animation => + animation.finished.catch(() => undefined), + ), + ); + } + }, + [property, value] as const, + ); +} + async function openCalendar( page: Page, {rtl = false, story = STORY}: {rtl?: boolean; story?: string} = {}, @@ -179,7 +216,7 @@ async function openCalendar( .getByRole('dialog') .locator('.astryx-bottom-sheet') .first(); - await waitForRest(sheet); + await settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); // Under a finger the hidden wheels warm up once the sheet has settled // (useWheelsWarmUp): their `visibility` turns visible at opacity 0, with its // own transition. Wait for its end, so a test's first tap meets a panel at @@ -191,7 +228,7 @@ async function openCalendar( timeout: SHEET_SETTLE_MS + 2000, }) .toBe('visible'); - await waitForRest(wheels); + await settlesAt(wheels, 'visibility', 'visible'); const title = page.locator('[data-title="month-year"]'); const titleBox = await title.boundingBox(); if (titleBox == null) { From 5db1b15853134b9724b3e48c985fb51ee164ad7e Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:41 -0800 Subject: [PATCH 24/40] fix(DateInput): a finger's tapped day selects at once; a mouse or pen keeps the fade, in any presentation The owner ruled on the day-tap fade per pointer: a finger's tapped day selects at once, as UIDatePicker's does and as the owner felt it on a device; a mouse or pen selection keeps main's fade, in any presentation. In either presentation the last press's pointer decides the fade, so a trackpad click or a stylus tap in the pager keeps it and a finger's tap in the scroller drops it. Before any press, as for a keyboard pick, the presentation's own default holds. DateInput FR9 records the rule. Unit cases press with each pointer type in both presentations, and browser cases tap with a finger, pick with a pen in the touch sheet and click with a mouse. --- .../calendar-toggle-uidatepicker-timing.md | 4 +- .../components/DateInput/DateInput.spec.md | 4 +- .../src/DateInput/DateInputTouch.test.tsx | 64 +++++++++++++- packages/core/src/DateInput/MonthPane.tsx | 15 +++- ...onthYearToggle.interaction.browser.spec.ts | 85 ++++++++++++++++++- 5 files changed, 159 insertions(+), 13 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index 3b98ae6a57f0f..d821954e18e3c 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -7,9 +7,9 @@ - The month/year toggle cross-fades over the theme duration token nearest iOS's 0.25 s (`--duration-medium-min`), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. - The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. -- A tapped day takes its selection at once, with no colour transition. +- A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. -With a mouse, the toggle and the day's transition stay as they are. +With a mouse or a pen, the toggle stays as it is. In DateTimeInput, when Done closes the wheels with focus in them, the keyboard focus moves to the shown month's day, with any pointer. diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index ccaab22c3959e..8f0dbc57afb5e 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -175,7 +175,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR6 | Named committed formats MUST follow the InternationalizationProvider locale, the resolved calendar (FR20), and the shared Timestamp vocabulary; formatting MUST NOT rewrite in-progress typed text. Typed text MUST parse in the provider locale (`architecture:internationalization` INV1a, INV8); in a day-first locale, numeric text outside the locale-ordered forms is not a date, so no fixed month-first reading applies. The touch sheet's month heading and each month pane's name are stand-alone month labels: they start with a capital where CLDR's stand-alone context transform titlecases the month, as `component:Calendar` FR1 states for Calendar's. Implementation gap: a four-digit year written first (`2026/01/12`) parses in every locale. | `architecture:internationalization`; current source, docs, locale tests; `spec:AST-043/FR5` | | FR7 | Every value-change path MUST call `onChange` before `changeAction`, present the proposed value optimistically, and expose one busy state while the Action is pending. | `family:input-fields/FR6`; current source and focused tests | | FR8 | The pointer field MUST open its Calendar from the input, calendar toggle, ArrowDown, or Alt+ArrowDown; Popover dismissal and focus return MUST remain owned by the shared Popover path. The open pointer Calendar stays open for a press on the field's input, clear button or label, or in the Calendar, and closes for a press elsewhere (`architecture:layer-runtime`) or for focus moving from anywhere in the field (its input, clear button or calendar toggle) to anything outside the field and the Calendar (`family:overlay-dismissal` AV4). | Current source, Popover composition, keyboard/focus tests | -| FR9 | A pointer-surface Calendar selection MUST commit and close its Popover; a bottom-sheet Calendar selection MUST remain editable in the open sheet until Save closes the surface. | Current source and pointer/touch tests | +| FR9 | A pointer-surface Calendar selection MUST commit and close its Popover; a bottom-sheet Calendar selection MUST remain editable in the open sheet until Save closes the surface; a finger's tapped day selects at once; a mouse or pen selection keeps the fade, in any presentation. | Current source and pointer/touch tests | | FR10 | Standalone mode MUST compose Field; admitted InputGroup mode MUST remove competing Field chrome while preserving name, description, value, keyboard, focus, and editing semantics. The field has no root styling props; `control*` reaches the control box on every surface, and `container*` reaches the Field box where one renders (in a horizontal-labels FormLayout none renders and `container*` warns; inside an InputGroup it is dropped) (`family:input-fields` FR10). The field height is the shared element size for its size and MUST grow with a set text size (`spec:AST-102` FR4). Implementation gap: the height is fixed. | `family:input-fields` FR3, FR10; `spec:AST-102` FR4; current source and tests | | FR11 | Clear MUST emit `undefined` through the ordinary change pipeline. The pointer and bottom-sheet surfaces restore field focus using their shipped timing; the native surface MUST NOT reclaim focus because doing so reopens the OS picker. Implementation gap: when the field becomes disabled or busy while its clear control holds focus, the control unmounts and focus falls to the document body; `spec:AST-085/FR4` requires focus to stay or move to a destination this record names. | Current source and focused clear tests; `spec:AST-085/FR4` | | FR12 | No surface renders native `disabled`. A disabled field MUST stay focusable with `aria-disabled`, block typing, picking, clearing, and opening, describe its disabled reason when one is given, and take disabled paint. While busy (`isLoading` or a pending Action) the pointer and bottom-sheet surfaces expose `aria-disabled` and `aria-busy`, stay focusable, and block new commits; the native surface does the same for `isLoading`, and during a pending Action stays editable so the OS picker remains attached but rejects additional commits until the Action settles. A busy field that is not disabled keeps its ordinary paint and Spinner, shows no hover ring while commits are blocked, and keeps its keyboard focus ring. While disabled or busy, the field ignores Enter and blur and shows no typed rejection. Typed text whose date typing has not reported stays, and the next Enter or blur once the field is editable commits or rejects it; a new `value` replaces it. Text whose date typing already reported through `onChange` gives way to the field's value once the field is editable again, whether or not the change took it; a focused field keeps the text until the next Enter or blur. Caller handlers other than focus and blur MUST NOT run while the field is disabled (`spec:AST-085` FR2). Implementation gap: only a caller `onClick` is skipped while disabled; caller key, paste, and pointer handlers (such as `onKeyDown`, `onKeyUp`, `onPaste`, and `onPointerDown`) still run on every surface, the native field included. | `spec:AST-085/FR1–FR6`; `family:input-fields/FR4–FR6`; `family:buttons/FR4`; current source and focused tests | @@ -401,7 +401,7 @@ clear-glyph target of its own. | FR6 | `DateInput.test.tsx` format cases, including "updates when the InternationalizationProvider locale changes (#5074)" and "does not apply format to in-progress typed input" and "typing a day-first date in en-GB never reports a month-first date"; `dateParser.test.ts` "in a day-first locale, never reads numeric text month-first outside the locale-ordered forms"; `NativeDateField.test.tsx` format and locale cases; `DateInputTouch.test.tsx` "updates the field and open picker when the provider locale changes"; `calendarDateParser.test.ts` "reads month and era names of the locale and English, in every width", "reads any Unicode decimal digits, in mixed scripts" | Named and function formats; locale change; localized month names and digits | A format that ignores the locale or rewrites typed text, or a localized committed value that cannot be retyped, fails. | `audit:DateInput/i18n` | | FR7, ORD3 | `DateInput.test.tsx` "runs changeAction after onChange for a typed date"; `NativeDateField.test.tsx` "runs changeAction after onChange and exposes the pending state"; `DateInputTouch.test.tsx` "runs changeAction and shows a busy state"; `BusyOwnerAnnouncement.test.tsx` DateInput cases; `DateInputTouch.test.tsx` "announces a busy start from a pick inside the open sheet, not behind it (spec:AST-086 FR8, Platform support)" | Typed, native, sheet | An Action before `onChange`, a missing busy state, or a missing busy announcement fails. | `audit:DateInput/behavior` | | FR8, AR2 | `DateInput.test.tsx` "opens the calendar popover on ArrowDown (keyboard, forms-13)", "opens the calendar popover on Alt+ArrowDown (keyboard, forms-13)", "input has aria-expanded attribute", "combobox input has aria-haspopup=\"dialog\" attribute", "moves focus into the calendar on each open from the toggle"; `DateInputTouch.test.tsx` "opens from the keyboard, APG combobox style"; `DateInput.test.tsx` "press inside and outside the open calendar" cases | Pointer and touch fields | A missing open path, combobox state, or focus move fails. Evidence gap: no test asserts Escape outside IME composition on the popover. | `audit:DateInput/accessibility` | -| FR9 | `DateInputTouch.test.tsx` "commits the tapped day and LEAVES the sheet open", "lets a second tap correct the first, still without closing", "Save closes the sheet without touching the value" | Sheet picks and Save | A sheet that closes on a pick or a Save that changes the value fails. Evidence gap: no test asserts that a popover Calendar pick closes the popover. | `audit:DateInput/behavior` | +| FR9 | `DateInputTouch.test.tsx` "commits the tapped day and LEAVES the sheet open", "lets a second tap correct the first, still without closing", "Save closes the sheet without touching the value", "a finger’s tapped day selects at once; a mouse or pen selection keeps the fade, in any presentation", "under the touch presentation, a mouse or pen pick keeps the fade", "in the scroller presentation, a finger’s tap selects at once and a mouse pick fades"; `MonthYearToggle.interaction.browser.spec.ts` "a finger’s tapped day selects at once", "a pen pick in the touch sheet keeps the fade", "a picked day’s selection keeps the fade, on the theme’s fast duration" | Sheet picks and Save | A sheet that closes on a pick or a Save that changes the value fails. Evidence gap: no test asserts that a popover Calendar pick closes the popover. | `audit:DateInput/behavior` | | FR10 | `DateInput.fr10.test.tsx` styling and per-surface cases ("has no root styling props and has the container* and control* parts", "rejects root xstyle, className, and style at compile time", "keeps control* on the control box in a horizontal-labels FormLayout") and handler-composition cases; `DateInput.test.tsx` InputGroup cases; `DateInputTouch.test.tsx` "drops the Field wrapper inside an InputGroup" | Standalone, grouped, horizontal labels; every surface | A root styling prop, `control*` off the control box, or Field chrome inside a group fails. Evidence gap: no test asserts the field height or its growth with text size (the FR10 gap). | `audit:DateInput/layout` | | FR11, ORD4 | `DateInput.test.tsx` hasClear cases; `DateInputTouch.test.tsx` "clears from the field", "returns focus to the field without letting the page scroll"; `NativeDateField.test.tsx` "clears the value without taking focus back", "runs clear through changeAction after onChange" | Pointer, sheet, native clear | A clear that does not emit `undefined`, a native clear that takes focus, or a clear shown while disabled fails. Evidence gap: no test covers the clear control unmounting under focus (the FR11 gap). | `audit:DateInput/behavior` | | FR12, AR4 | `DateInput.test.tsx` disabled, loading, and disabledMessage cases (including "does not call a caller onClick while disabled (AST-085 FR2)" and "links the reason tooltip from the calendar button too (AST-085 FR5)"); `DateInputTouch.test.tsx` open-sheet disablement and disabled cases; `NativeDateField.test.tsx` "keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)", "does not call a caller onClick while disabled (AST-085 FR2)"; `DateInput.test.tsx` disabled theme-state cases | Disabled with and without a reason; loading; open picker becoming disabled | Native `disabled`, a change or open while disabled, a caller `onClick` while disabled, a reason not linked from each focusable control, or a missing `aria-busy` fails. Evidence gap: no test asserts that caller key, paste, or pointer handlers are skipped while disabled (the FR12 gap) or the busy-only paint. | `audit:DateInput/accessibility` | diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 0f82cf37af343..a22609329510c 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -3888,19 +3888,77 @@ describe('DateInput — scroll CSS (definition-level)', () => { ); }); - it('under a finger: selects a tapped day at once, as UIDatePicker does', () => { - // datePicker.calendar.selection.tapAnimates: no animation on a tap. The - // shared pane fades a selection only for MonthScroller, the mouse calendar. + it('a finger\u2019s tapped day selects at once; a mouse or pen selection keeps the fade, in any presentation', () => { + // UIDatePicker takes a tap's selection at once + // (datePicker.calendar.selection.tapAnimates). MonthScroller, the calendar + // of a mouse or a pen, always fades; the pager fades a mouse's or a pen's + // pick from its pointer type. const pane = read('MonthPane.tsx'); const puck = declarations(pane, 'puck'); expect(puck.some(line => line.startsWith('transition'))).toBe(false); expect(declarations(pane, 'puckFade')).toContain( "transitionProperty: 'background-color, color, border-color'", ); + expect(declarations(pane, 'puckFade')).toContain( + "transitionDuration: durationVars['--duration-fast']", + ); expect(read('MonthScroller.tsx')).toContain('hasSelectionFade'); expect(read('MonthPager.tsx')).not.toContain('hasSelectionFade'); }); + it('under the touch presentation, a mouse or pen pick keeps the fade', () => { + renderAndOpen(); + const day = pane('March 2026').querySelector<HTMLElement>( + '[data-date="2026-03-12"]', + ); + const puck = day?.firstElementChild as HTMLElement | null; + if (day == null || puck == null) { + throw new Error('no day'); + } + const resting = puck.className; + const press = (pointerType: string) => { + const event = new Event('pointerdown', {bubbles: true}); + Object.assign(event, {pointerType}); + act(() => { + day.dispatchEvent(event); + }); + return puck.className; + }; + // A finger's press leaves the puck as it rests; a mouse's or a pen's + // gives it the fade before the selection lands. + expect(press('touch')).toBe(resting); + expect(press('pen')).not.toBe(resting); + expect(press('mouse')).not.toBe(resting); + expect(press('touch')).toBe(resting); + }); + + it('in the scroller presentation, a finger\u2019s tap selects at once and a mouse pick fades', () => { + stubTouchscreenLaptop(); + renderAndOpen( + <Controlled initial="2026-03-21" presentation="bottom-sheet" />, + ); + const day = pane('March 2026').querySelector<HTMLElement>( + '[data-date="2026-03-12"]', + ); + const puck = day?.firstElementChild as HTMLElement | null; + if (day == null || puck == null) { + throw new Error('no day'); + } + // The scroller fades a pick before any press: a keyboard pick fades. + const resting = puck.className; + const press = (pointerType: string) => { + const event = new Event('pointerdown', {bubbles: true}); + Object.assign(event, {pointerType}); + act(() => { + day.dispatchEvent(event); + }); + return puck.className; + }; + expect(press('touch')).not.toBe(resting); + expect(press('mouse')).toBe(resting); + expect(press('pen')).toBe(resting); + }); + it('under a finger: cross-fades the DateTimeInput sheet\u2019s month/year toggle on the same clock', () => { const source = read('../DateTimeInput/TouchDateTimeField.tsx'); // The calendar and the wheels cross-fade, and the header's actions fade, diff --git a/packages/core/src/DateInput/MonthPane.tsx b/packages/core/src/DateInput/MonthPane.tsx index 0dc20d7d977be..0e72bfda558f6 100644 --- a/packages/core/src/DateInput/MonthPane.tsx +++ b/packages/core/src/DateInput/MonthPane.tsx @@ -12,7 +12,7 @@ * SYNC: When modified, update DateInputTouch.test.tsx. */ -import {memo, useMemo} from 'react'; +import {memo, useMemo, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; import {useLocale} from '../i18n/useLocale'; @@ -236,6 +236,11 @@ export const MonthPane = memo(function MonthPane({ hasSelectionFade = false, }: MonthPaneProps) { const locale = useLocale(); + // The last press's pointer decides the fade in either presentation: a mouse + // or pen pick fades, and a finger's tap selects at once. Before any press, + // the presentation's own default holds. + const [pointerFade, setPointerFade] = useState<boolean | null>(null); + const isSelectionFade = pointerFade ?? hasSelectionFade; const {calendar} = monthModel; const month = useMemo( () => monthModel.monthAt(monthIndex), @@ -311,6 +316,12 @@ export const MonthPane = memo(function MonthPane({ )} aria-disabled={isDisabled || undefined} aria-current={isToday ? 'date' : undefined} + onPointerDown={event => { + setPointerFade( + event.pointerType === 'mouse' || + event.pointerType === 'pen', + ); + }} onClick={() => { if (!isDisabled) { onSelect(day.iso); @@ -336,7 +347,7 @@ export const MonthPane = memo(function MonthPane({ <span {...stylex.props( styles.puck, - hasSelectionFade && styles.puckFade, + isSelectionFade && styles.puckFade, !isDisabled && styles.puckHoverable, isToday && styles.puckToday, isSelected && styles.puckSelected, diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 37c1123cbe984..1dd485bef3878 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -33,6 +33,7 @@ import { touchDriver, waitForRest, watchAnimations, + webInputDriver, withStorybook, writeEvidence, type ActiveAnimation, @@ -780,7 +781,7 @@ test('in RTL the chevron points left, and turns to point down', async ({ expect(await pointing()).toEqual({x: 0, y: 1}); }); -test('a tapped day takes its selection at once', async ({page}) => { +test('a finger’s tapped day selects at once', async ({page}) => { const {driver} = await openCalendar(page); const day = page.getByRole('button', {name: /October 20, 2026/}); const box = await day.boundingBox(); @@ -789,13 +790,89 @@ test('a tapped day takes its selection at once', async ({page}) => { } await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); // No transition on the day: nothing is animating once the tap is handled. - const running = await day.evaluate( - element => element.getAnimations({subtree: true}).length, + const puck = await day.evaluate(element => ({ + duration: Number.parseFloat( + getComputedStyle(element.firstElementChild ?? element).transitionDuration, + ), + running: element.getAnimations({subtree: true}).length, + })); + expect(puck).toEqual({duration: 0, running: 0}); + await expect(page.getByRole('gridcell', {selected: true})).toContainText( + '20', + ); +}); + +test('a pen pick in the touch sheet keeps the fade', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Only Chromium’s driver sends a pen as a pen', ); - expect(running).toBe(0); + await openCalendar(page); + const day = page.getByRole('button', {name: /October 20, 2026/}); + const box = await day.boundingBox(); + if (box == null) { + throw new Error('no day'); + } + const at = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + await ( + await webInputDriver(page) + ).pen([ + {...at, t: 0}, + {...at, t: 40}, + ]); await expect(page.getByRole('gridcell', {selected: true})).toContainText( '20', ); + const fade = await day.evaluate(element => { + const style = getComputedStyle(element.firstElementChild ?? element); + const ms = (value: string) => + value.trim().endsWith('ms') + ? Number.parseFloat(value) + : Number.parseFloat(value) * 1000; + return { + duration: ms(style.transitionDuration), + fast: ms(style.getPropertyValue('--duration-fast')), + }; + }); + expect(fade.duration).toBeGreaterThan(0); + expect(fade.duration).toBe(fade.fast); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a picked day’s selection keeps the fade, on the theme’s fast duration', async ({ + page, + }) => { + await openStory(page, storybook(), STORY); + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); + const day = page.getByRole('button', {name: /October 20, 2026/}); + await day.click(); + await expect(page.getByRole('gridcell', {selected: true})).toContainText( + '20', + ); + const fade = await day.evaluate(element => { + const style = getComputedStyle(element.firstElementChild ?? element); + const ms = (value: string) => + value.trim().endsWith('ms') + ? Number.parseFloat(value) + : Number.parseFloat(value) * 1000; + return { + property: style.transitionProperty, + duration: ms(style.transitionDuration), + fast: ms(style.getPropertyValue('--duration-fast')), + }; + }); + expect(fade.property).toBe('background-color, color, border-color'); + expect(fade.duration).toBeGreaterThan(0); + expect(fade.duration).toBe(fade.fast); + }); }); /** The computed state of the hidden wheels panel and its fade. */ From 11ab87600ac1789b0aebc591488295c974e2547c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Thu, 5 Nov 2026 02:15:42 -0800 Subject: [PATCH 25/40] docs(DateInput, DateTimeInput): state the toggle's shield and focus DateInput FR25 states what the month/year toggle does under a finger: no tap reaches either panel while it cross-fades, the actions that leave with the toggle stop taking taps at once, and the incoming ones take taps when the outgoing panel's fade ends. It also states the focus hand-off: when the title or Done toggles while focus is inside the outgoing panel, focus moves into the incoming one. DateTimeInput FR15 points its toggle at FR25. Two tests complete FR25's evidence: DateInput's Done and DateTimeInput's title hand focus to the incoming panel in the toggle's commit. Both fail on the parent pull request's head. The changeset scopes the duration token to the neutral theme, where it is the token nearest iOS's 0.25 s. --- .../calendar-toggle-uidatepicker-timing.md | 4 +- .../components/DateInput/DateInput.spec.md | 2 + .../DateTimeInput/DateTimeInput.spec.md | 2 +- .../src/DateInput/DateInputTouch.test.tsx | 48 ++++++++++++++++ .../DateTimeInput/DateTimeInputTouch.test.tsx | 55 +++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index d821954e18e3c..d6b86dace84a3 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -4,14 +4,14 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: -- The month/year toggle cross-fades over the theme duration token nearest iOS's 0.25 s (`--duration-medium-min`), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. +- The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. - The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. With a mouse or a pen, the toggle stays as it is. -In DateTimeInput, when Done closes the wheels with focus in them, the keyboard focus moves to the shown month's day, with any pointer. +When the title or Done toggles with keyboard focus in the outgoing panel, focus moves into the incoming one, with any pointer. In DateTimeInput, Done lands on the shown month's day. With Reduce Motion on, nothing travels: under a finger the toggle cross-fades in 200 ms, and the chevron dips instead of turning, its turn landing mid-dip. With a mouse it changes at once. diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 8f0dbc57afb5e..dc80ef9d163ec 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -191,6 +191,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR22 | In the touch sheet, a month arrow at the range end is hidden; if it held focus, focus moves first to the other month arrow, or to the month-and-year button when both are hidden (`spec:AST-085/FR4`). | `spec:AST-085/FR4`; current source and sheet arrow tests | | FR23 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | | FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the theme duration token nearest UIKit's measured 250 ms in the Storybook theme (`--duration-medium-min`, 225 ms there) and the move easing (`--ease-move`), as `spec:AST-094` FR45 asks. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | +| FR25 | Under a coarse primary pointer, while the bottom-sheet calendar's month/year toggle cross-fades, a tap MUST reach neither panel: the outgoing panel and the header's and footer's actions that leave with it stop taking taps with the toggle, and the incoming ones take taps once the outgoing panel's fade ends. Under a fine pointer they swap with the toggle. When the title or Done toggles while focus is inside the outgoing panel, focus MUST move into the incoming panel once that panel can take focus, onto the shown month's tab stop in the calendar; focus outside the panels stays where it is. | UIDatePicker on the iOS 26.2 simulator, whose toggle takes no touch mid-fade; the APG grid's single tab stop | ### Allowed variation @@ -422,6 +423,7 @@ clear-glyph target of its own. | FR22 | `DateInputTouch.test.tsx` "moves focus to the other arrow when the focused arrow reaches the end of the range and hides" and "moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus" | Sheet month arrows at the first and last month of a range | Focus left on a hidden arrow, or dropped to the document body, fails. | `audit:DateInput/behavior` | | FR23 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | | FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on the theme’s tokens nearest UIKit’s toggle", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | +| FR25 | `useFadeInert.test.tsx` "makes the outgoing panel inert when its fade ends, never with the toggle", "swaps inert with the toggle with a mouse, fade or not", "moves focus inside the outgoing panel to the incoming one in the toggle’s commit", "leaves focus outside the panels where it is"; `MonthYearToggle.interaction.browser.spec.ts` "a tap on Done during the opening fade does not reach Save beneath it", "DateTimeInput: a tap on the month arrows during the opening fade changes nothing", "DateInput: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends"; `DateInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit", "Done hands focus from the wheels to the calendar in the toggle’s commit"; `DateTimeInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit" | A tap mid-fade on Done, the arrows or a panel; a toggle with focus in either panel; a mouse toggle | A tap that reaches a panel or an incoming action before the fade ends, an outgoing action that still takes taps after the toggle, or focus left in a fading panel fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index bd44d4224a69e..5fd9c09157195 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -145,7 +145,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR12 | With a calendar other than `gregory` or `iso8601`, every presentation that would show native controls — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the Astryx popovers on a fine pointer and the bottom sheet on a coarse pointer, for the date and the time alike, because the native date control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet's Date panel follows `component:DateInput` FR21: calendar months in six-row panes, the month wheel of the selected calendar year with leap months, the common counterpart on a year change, and year labels with their era; its date segment writes the date as `date_long` in the calendar (`spec:AST-111` FR16). The combined value stays a Gregorian ISO date-time (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation and touch calendar tests | | FR13 | While disabled or busy, the date segment keeps or drops typed text as `component:DateInput` FR12 states, judging it on the next Enter or blur once editable. | `spec:AST-085` FR2; current source and tests | | FR14 | Under a coarse primary pointer, the date sheet's month and year wheels MUST move a chosen date as `component:DateInput` FR23 does, keeping its time. | `component:DateInput` FR23; UIDatePicker on the iOS 26.2 simulator | -| FR15 | Under a coarse primary pointer, the date sheet's title chevron MUST point and turn as `component:DateInput` FR24 describes. When Done hides the month/year wheels while focus is inside them, focus MUST move to the shown month's tab stop once the calendar can take focus, not to a day of a month mounted off-screen. | `component:DateInput` FR24; the APG grid's single tab stop | +| FR15 | Under a coarse primary pointer, the date sheet's title chevron MUST point and turn as `component:DateInput` FR24 describes, and its month/year toggle MUST take taps and hand off focus as `component:DateInput` FR25 describes. When Done hides the month/year wheels while focus is inside them, focus MUST move to the shown month's tab stop once the calendar can take focus, not to a day of a month mounted off-screen. | `component:DateInput` FR24 and FR25; the APG grid's single tab stop | ### Allowed variation diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index a22609329510c..4919f9efbdc94 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -2465,6 +2465,54 @@ describe('DateInput — month/year wheels', () => { expect(fromDay.commits).toBe(fromTitle.commits); }); + it('Done hands focus from the wheels to the calendar in the toggle\u2019s commit', () => { + // One Done press with focus on `target`: its commits, and where focus ends. + const pressDone = (target: () => HTMLElement) => { + let commits = 0; + withLayout(() => { + render( + <Profiler + id="sheet" + onRender={() => { + commits += 1; + }}> + <Controlled initial="2026-03-21" /> + </Profiler>, + ); + fireEvent.click(field()); + }); + openWheels(); + // A running exit on both panels, so neither settles inside the press. + for (const which of ['calendar', 'wheels'] as const) { + (panel(which) as HTMLElement).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + target().focus(); + commits = 0; + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + const result = { + commits, + isInCalendar: panel('calendar').contains(document.activeElement), + focusedDate: (document.activeElement as HTMLElement | null)?.dataset + .date, + }; + cleanup(); + return result; + }; + // A keyboard user can leave focus on a wheel while a pointer presses Done. + const fromWheel = pressDone(() => + screen.getByRole('listbox', {name: 'Month'}), + ); + const fromTitle = pressDone(title); + expect(fromWheel.isInCalendar).toBe(true); + expect(fromWheel.focusedDate).toBe('2026-03-21'); + expect(fromTitle.isInCalendar).toBe(false); + // The hand-off rides the toggle's own commit and costs none of its own. + expect(fromWheel.commits).toBe(fromTitle.commits); + }); + it('the year wheel keeps the month', () => { renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />); openWheels(); diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index d76ebb101e68f..ab8abda038602 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -1005,6 +1005,61 @@ describe('DateTimeInput touch surface', () => { expect(fromDone.commits).toBe(fromTitle.commits); }); + it('a title press hands focus from the calendar to the wheels in the toggle’s commit', () => { + // One title press with focus on a day or on the title: its commits, and + // where focus ends. + const pressTitle = (isFocusOnDay: boolean) => { + let commits = 0; + withMonthLayout(() => { + render( + <Profiler + id="sheet" + onRender={() => { + commits += 1; + }}> + <DateTimeInput + label="Meeting" + value={'2026-03-15T10:00' as ISODateTimeString} + onChange={() => {}} + /> + </Profiler>, + ); + openDateSheet(); + }); + const surface = (which: 'calendar' | 'wheels') => + document.querySelector<HTMLElement>(`[data-date-surface="${which}"]`)!; + const title = document.querySelector<HTMLElement>( + 'button[aria-label$="Choose month and year"]', + )!; + // An assistive technology can press the title while focus stays on a day. + (isFocusOnDay + ? surface('calendar').querySelector<HTMLElement>('[data-date]')! + : title + ).focus(); + // A running exit on both surfaces, so neither settles inside the press. + for (const which of ['calendar', 'wheels'] as const) { + surface(which).getAnimations = () => + [ + {transitionProperty: 'opacity', playState: 'running'}, + ] as unknown as Animation[]; + } + commits = 0; + fireEvent.click(title); + const result = { + commits, + isInWheels: surface('wheels').contains(document.activeElement), + }; + cleanup(); + return result; + }; + const fromDay = pressTitle(true); + const fromTitle = pressTitle(false); + expect(fromDay.isInWheels).toBe(true); + expect(fromTitle.isInWheels).toBe(false); + // The hand-off rides the toggle's own commit and costs none of its own. + expect(fromDay.commits).toBe(fromTitle.commits); + }); + it('keeps Save date disabled until a valid date exists', () => { withMonthLayout(() => { render(<ControlledDateTimeInput />); From 73e8382c302b2519ae62bb30f1105560fc35800f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Fri, 6 Nov 2026 11:02:26 -0800 Subject: [PATCH 26/40] docs(changeset): the toggle's note says what the wheels' warm-up does The note said the warm-up makes the fade show from near its start. On the iOS Simulator at this head it does not: the toggle's commit holds the main thread for about 45 ms, as it does on main, and Mobile Safari clocks the fade from the style change, so its first frame shows it 54-82 % done. The note now says only what the warm-up does: the wheels are laid out once the sheet settles, so a toggle after that only changes their opacity. --- .changeset/calendar-toggle-uidatepicker-timing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index d6b86dace84a3..b659412678644 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -5,7 +5,7 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: - The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. -- The hidden month/year wheels are laid out once the sheet settles, so the fade shows from near its start. +- The hidden month/year wheels are laid out once the sheet settles, so a toggle after that only changes their opacity. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. From c4fe3f87249122a5e633735ccdbb847750a3b8a5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Sat, 7 Nov 2026 01:14:00 -0800 Subject: [PATCH 27/40] test(DateInput): the toggle's tests follow main #10231 moves focus off a month arrow that hides at the range end. Two tests check that the hand-off still runs through what the toggle keeps: - the arrows, which the toggle does not re-render: a press after the toggle still moves focus to the other arrow; - the month grid's callback, which the toggle leaves unchanged, so the memoized pager does not re-render: a month the pager settles on after the toggle still moves focus the same way. Each test fails if the toggle re-renders what it keeps, or if the hand-off is lost. #10386 has an armed hold name the motion it judges. The toggle storyboard's hold judges the wheels' opacity, which the fade moves. --- .../src/DateInput/DateInputTouch.test.tsx | 61 +++++++++++++++++++ ...onthYearToggle.interaction.browser.spec.ts | 4 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 4919f9efbdc94..6d52cf8320b5f 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -73,6 +73,8 @@ import type * as IconButtonModule from '../IconButton'; import {MonthYearWheels} from './MonthYearWheels'; import type * as MonthYearWheelsModule from './MonthYearWheels'; import {SHEET_SETTLE_MS} from './useWheelsWarmUp'; +import {MonthPager} from './datePickerTouch'; +import type * as DatePickerTouchModule from './datePickerTouch'; // A pass-through spy: each call is one month pane rendering, which the // month/year toggle test counts. It wraps the module `MonthPane` imports. @@ -95,6 +97,14 @@ vi.mock('./MonthYearWheels', async importOriginal => { const actual = await importOriginal<typeof MonthYearWheelsModule>(); return {...actual, MonthYearWheels: vi.fn(actual.MonthYearWheels)}; }); +// A pass-through render spy inside MonthPager's memo: each call is one pager +// render, which the month/year toggle test reads (the toggle must not +// re-render it). +vi.mock('./datePickerTouch', async importOriginal => { + const actual = await importOriginal<typeof DatePickerTouchModule>(); + const {memo} = await import('react'); + return {...actual, MonthPager: memo(vi.fn(actual.MonthPager.type))}; +}); // --------------------------------------------------------------------------- // jsdom scaffolding @@ -3415,6 +3425,57 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(vi.mocked(MonthYearWheels)).toHaveBeenCalledTimes(1); }); + it('under a finger: the arrows the toggle keeps still move focus on at the range end', async () => { + // The toggle re-renders neither month arrow, so a press after the toggle + // runs the arrows' earlier callbacks. They must still move focus off an + // arrow that hides. + renderAndOpen( + <Controlled initial="2026-03-10" min="2026-01-01" max="2026-04-30" />, + ); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); + }); + const title = () => + document.querySelector<HTMLElement>('[data-title="month-year"]')!; + vi.mocked(IconButton).mockClear(); + fireEvent.click(title()); + fireEvent.click(title()); + expect(vi.mocked(IconButton)).not.toHaveBeenCalled(); + const previous = screen.getByRole('button', {name: 'Previous month'}); + act(() => previous.focus()); + fireEvent.click(previous); + fireEvent.click(screen.getByRole('button', {name: 'Previous month'})); + // January is the first month: Previous month hides, and focus moves to + // the other arrow. + expect(screen.getByRole('button', {name: 'Next month'})).toHaveFocus(); + }); + + it('under a finger: the month grid\u2019s callback the toggle keeps still moves focus on when a settled month reaches the range end', async () => { + // The toggle leaves the callback unchanged, so the memoized pager does not + // re-render. A month the pager settles on after the toggle reaches the + // callback from before it, which must still move focus off the arrow + // that hides. + renderAndOpen( + <Controlled initial="2026-03-10" min="2026-01-01" max="2026-04-30" />, + ); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); + }); + const title = () => + document.querySelector<HTMLElement>('[data-title="month-year"]')!; + const pagerRenders = vi.mocked(MonthPager.type); + const settle = pagerRenders.mock.lastCall![0].onVisibleMonthChange; + pagerRenders.mockClear(); + fireEvent.click(title()); + fireEvent.click(title()); + expect(pagerRenders).not.toHaveBeenCalled(); + const previous = screen.getByRole('button', {name: 'Previous month'}); + act(() => previous.focus()); + // The pager settles on January, the first month: Previous month hides. + act(() => settle(toMonthIndex(2026, 1))); + expect(screen.getByRole('button', {name: 'Next month'})).toHaveFocus(); + }); + it('under a finger: MonthPager pages the months with the shared pager, each pane on its own layer', () => { const source = read('MonthPager.tsx'); // UIKit's paging scroll view, not native scroll snapping: iOS gives a diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 1dd485bef3878..8f4fa5a4e41e9 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -1204,7 +1204,9 @@ test( await installTimerGate(page); // The page holds the fade itself as soon as it runs. A hold sent after // the tap can miss the toggle's fade on a loaded runner. - const hold = await armHold(page, sheet); + const hold = await armHold(page, sheet, { + judges: {target: wheels, property: 'opacity'}, + }); await driver.tap(titleAt); const held = await hold.held(); await freezeTimers(page); From dc2e1fce7b897e20a9b9cae2804a5c60135f940d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Sat, 7 Nov 2026 01:14:00 -0800 Subject: [PATCH 28/40] docs(changeset): the toggle's note says what the warm-up spares it DateInput's panel also flips pointer-events with the toggle, so a toggle after the warm-up changes more than the wheels' opacity. The line now says what the warm-up spares the toggle: laying the wheels out. --- .changeset/calendar-toggle-uidatepicker-timing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index b659412678644..d93ec0062d78a 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -5,7 +5,7 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: - The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. -- The hidden month/year wheels are laid out once the sheet settles, so a toggle after that only changes their opacity. +- The hidden month/year wheels are laid out once the sheet settles, so a toggle after that does not lay them out. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. From 947120eda9958873fabc321ed9194b576d11edcb Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Tue, 10 Nov 2026 15:24:50 -0800 Subject: [PATCH 29/40] refactor(DateInput): reach the month track without a data attribute The pager's track carried data-month-track, a new public data attribute that only the browser proofs read. The pager already holds the track through trackRef, so the attribute goes. The proofs reach the track as the months viewport's only child, and the mouse case tells the snap scroller from the pager by its overflow. --- packages/core/src/DateInput/MonthPager.tsx | 2 +- .../MonthPager.interaction.browser.spec.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 45680fb04da23..0bfb1bc882cd7 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -305,7 +305,7 @@ function MonthPagerComponent({ ref={viewportRef} data-scroller="months" {...stylex.props(styles.viewport)}> - <div ref={trackRef} data-month-track="" {...stylex.props(styles.track)}> + <div ref={trackRef} {...stylex.props(styles.track)}> {paneSize > 0 && rowsIn(visibleRows).map(row => ( <MonthPane diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 91f4242dee893..f8d373b637125 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -138,7 +138,8 @@ async function openCalendar( driver, sheet, viewport, - track: page.locator('[data-month-track]'), + // The track is the months viewport's only child. + track: viewport.locator(':scope > *'), title: page.locator('[data-title="month-year"]'), width: rect.width, center: {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}, @@ -148,7 +149,7 @@ async function openCalendar( /** The content offset in months: 0 at January in this story. */ async function monthsIn(viewport: Locator): Promise<number> { return viewport.evaluate(element => { - const track = element.querySelector('[data-month-track]'); + const track = element.firstElementChild; if (track == null) { return NaN; } @@ -181,7 +182,10 @@ test.describe('with a mouse', () => { .click(); const viewport = page.locator('[data-scroller="months"]'); await expect(viewport).toBeVisible(); - expect(await page.locator('[data-month-track]').count()).toBe(0); + // The pager's viewport clips its track; the snap scroller's scrolls. + expect( + await viewport.evaluate(element => getComputedStyle(element).overflowX), + ).not.toBe('hidden'); expect( await viewport.evaluate( element => getComputedStyle(element).scrollSnapType, @@ -419,7 +423,7 @@ async function startOffsetLog(viewport: Locator) { await viewport.evaluate(element => { const log: number[] = []; (window as unknown as {offsets: number[]}).offsets = log; - const track = element.querySelector('[data-month-track]'); + const track = element.firstElementChild; const t0 = performance.now(); const tick = () => { if (track != null) { @@ -694,7 +698,7 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 const play = async (script: Parameters<typeof driver.play>[0]) => { await sheet.evaluate((sheetElement, top) => { const months = document.querySelector('[data-scroller="months"]'); - const track = months?.querySelector('[data-month-track]'); + const track = months?.firstElementChild; const seen = { sheetDrop: 0, monthsMin: Infinity, @@ -866,7 +870,7 @@ test.describe('a trackpad (spec:AST-094 FR43)', () => { } return { field, - track: page.locator('[data-month-track]'), + track: viewport.locator(':scope > *'), at: {x: box.x + box.width / 2, y: box.y + box.height / 2}, }; } From b5a487abd2a24e683fce0fafa29ddf3bee0e38e5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Tue, 10 Nov 2026 15:24:50 -0800 Subject: [PATCH 30/40] refactor(DateInput): reach the month track without a data attribute The pager's track carried data-month-track, a new public data attribute that only the browser proofs read. The pager already holds the track through trackRef, so the attribute goes. The proofs reach the track as the months viewport's only child, and the mouse case tells the snap scroller from the pager by its overflow. --- packages/core/src/DateInput/MonthPager.tsx | 2 +- .../MonthPager.interaction.browser.spec.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 45680fb04da23..0bfb1bc882cd7 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -305,7 +305,7 @@ function MonthPagerComponent({ ref={viewportRef} data-scroller="months" {...stylex.props(styles.viewport)}> - <div ref={trackRef} data-month-track="" {...stylex.props(styles.track)}> + <div ref={trackRef} {...stylex.props(styles.track)}> {paneSize > 0 && rowsIn(visibleRows).map(row => ( <MonthPane diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index 91f4242dee893..f8d373b637125 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -138,7 +138,8 @@ async function openCalendar( driver, sheet, viewport, - track: page.locator('[data-month-track]'), + // The track is the months viewport's only child. + track: viewport.locator(':scope > *'), title: page.locator('[data-title="month-year"]'), width: rect.width, center: {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}, @@ -148,7 +149,7 @@ async function openCalendar( /** The content offset in months: 0 at January in this story. */ async function monthsIn(viewport: Locator): Promise<number> { return viewport.evaluate(element => { - const track = element.querySelector('[data-month-track]'); + const track = element.firstElementChild; if (track == null) { return NaN; } @@ -181,7 +182,10 @@ test.describe('with a mouse', () => { .click(); const viewport = page.locator('[data-scroller="months"]'); await expect(viewport).toBeVisible(); - expect(await page.locator('[data-month-track]').count()).toBe(0); + // The pager's viewport clips its track; the snap scroller's scrolls. + expect( + await viewport.evaluate(element => getComputedStyle(element).overflowX), + ).not.toBe('hidden'); expect( await viewport.evaluate( element => getComputedStyle(element).scrollSnapType, @@ -419,7 +423,7 @@ async function startOffsetLog(viewport: Locator) { await viewport.evaluate(element => { const log: number[] = []; (window as unknown as {offsets: number[]}).offsets = log; - const track = element.querySelector('[data-month-track]'); + const track = element.firstElementChild; const t0 = performance.now(); const tick = () => { if (track != null) { @@ -694,7 +698,7 @@ test('in the sheet, a drag is decided once it has moved 10 px either way: slow 3 const play = async (script: Parameters<typeof driver.play>[0]) => { await sheet.evaluate((sheetElement, top) => { const months = document.querySelector('[data-scroller="months"]'); - const track = months?.querySelector('[data-month-track]'); + const track = months?.firstElementChild; const seen = { sheetDrop: 0, monthsMin: Infinity, @@ -866,7 +870,7 @@ test.describe('a trackpad (spec:AST-094 FR43)', () => { } return { field, - track: page.locator('[data-month-track]'), + track: viewport.locator(':scope > *'), at: {x: box.x + box.width / 2, y: box.y + box.height / 2}, }; } From 8ddf2aef8278a3cb38a6b938a0a1ebcbe4e32b2a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Tue, 10 Nov 2026 21:03:50 -0800 Subject: [PATCH 31/40] chore(sandbox): date sheets on a page the document scrolls The DateInput sheet over two centuries and the DateTimeInput sheet sit on a raw page that scrolls with the document. A phone check of their month and year toggle runs on this page instead of Storybook. --- apps/sandbox/scripts/next-export-routes.json | 1 + .../(raw)/pages/month-year-toggle/page.tsx | 64 +++++++++++++++++++ apps/sandbox/src/app/sandboxPages.ts | 6 ++ 3 files changed, 71 insertions(+) create mode 100644 apps/sandbox/src/app/(raw)/pages/month-year-toggle/page.tsx diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index 990fe6f6cf767..152dd3d8dcb99 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -31,6 +31,7 @@ "/pages/mobile-prototypes/", "/pages/mobile-spacing/", "/pages/mobile-type/", + "/pages/month-year-toggle/", "/pages/motion-examples/", "/pages/motion-lab/", "/pages/motion-lab/bugs/", diff --git a/apps/sandbox/src/app/(raw)/pages/month-year-toggle/page.tsx b/apps/sandbox/src/app/(raw)/pages/month-year-toggle/page.tsx new file mode 100644 index 0000000000000..7d56bddbbbdfd --- /dev/null +++ b/apps/sandbox/src/app/(raw)/pages/month-year-toggle/page.tsx @@ -0,0 +1,64 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file page.tsx + * @input None + * @output A bare page with a DateInput sheet over two centuries, a + * DateTimeInput sheet, and rows that make the document scroll + * @position A raw page, with no sandbox chrome, for trying the month and year + * toggle in both sheets on a phone. The page scrolls with the document, as + * an app's page does. + */ + +import {useState} from 'react'; +import type {ISODateString} from '@astryxdesign/core/Calendar'; +import {DateInput} from '@astryxdesign/core/DateInput'; +import {DateTimeInput} from '@astryxdesign/core/DateTimeInput'; +import type {ISODateTimeString} from '@astryxdesign/core/DateTimeInput'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Heading, Text} from '@astryxdesign/core/Text'; + +const ROWS = Array.from({length: 40}, (_, index) => `Row ${index + 1}`); + +export default function MonthYearTogglePage() { + const [date, setDate] = useState<ISODateString | undefined>( + '2026-03-15' as ISODateString, + ); + const [dateTime, setDateTime] = useState<ISODateTimeString | undefined>( + undefined, + ); + return ( + <VStack gap={4} padding={4}> + <VStack gap={1}> + <Heading level={1}>Month and year toggle</Heading> + <Text color="secondary"> + Tap a field, then tap the month title in its sheet. + </Text> + </VStack> + <DateInput + label="Date of birth" + presentation="bottom-sheet" + min={'1900-01-01' as ISODateString} + max={'2099-12-31' as ISODateString} + value={date} + onChange={setDate} + /> + <DateTimeInput + label="Meeting time" + placeholder="Select a date" + presentation="bottom-sheet" + value={dateTime} + onChange={setDateTime} + /> + <VStack gap={2}> + {ROWS.map(row => ( + <Text key={row} color="secondary"> + {row} + </Text> + ))} + </VStack> + </VStack> + ); +} diff --git a/apps/sandbox/src/app/sandboxPages.ts b/apps/sandbox/src/app/sandboxPages.ts index 41869b931d08f..f7feedd594ee1 100644 --- a/apps/sandbox/src/app/sandboxPages.ts +++ b/apps/sandbox/src/app/sandboxPages.ts @@ -182,6 +182,12 @@ export const categories: SandboxCategory[] = [ description: 'Every built-in Text type at the default size, on a bare page that scrolls', }, + { + name: 'Month and Year Toggle', + href: '/pages/month-year-toggle/', + description: + 'The DateInput and DateTimeInput sheets, whose month title opens the month and year wheels, on a page that scrolls with the document', + }, ], }, { From e0fc1d6686acd0e26828171bd1d8a94f664231e5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 00:05:01 -0800 Subject: [PATCH 32/40] refactor(DateInput): keep the month grid in MonthScroller MonthPane, monthGridKeyTarget and useMonthGridFocus move back into MonthScroller.tsx, and MonthPager imports them from there. No behavior changes. --- .../DateInputTouch.calendar.test.tsx | 2 +- .../src/DateInput/DateInputTouch.test.tsx | 25 +- packages/core/src/DateInput/MonthPager.tsx | 8 +- packages/core/src/DateInput/MonthPane.tsx | 383 -------------- packages/core/src/DateInput/MonthScroller.tsx | 485 +++++++++++++++++- .../core/src/DateInput/useMonthGridFocus.ts | 139 ----- 6 files changed, 499 insertions(+), 543 deletions(-) delete mode 100644 packages/core/src/DateInput/MonthPane.tsx delete mode 100644 packages/core/src/DateInput/useMonthGridFocus.ts diff --git a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx index d62c871dcaaf8..a5f2bf9dde255 100644 --- a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx @@ -19,7 +19,7 @@ import { type ISODateTimeString, } from '../DateTimeInput/DateTimeInput'; import {InternationalizationProvider} from '../i18n/InternationalizationProvider'; -import {monthGridKeyTarget} from './MonthPane'; +import {monthGridKeyTarget} from './MonthScroller'; import {createMonthModel, dateInMonth} from './monthGeometry'; import { type CalendarSystem, diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 56ab01921b67e..67e4f598b32e7 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -3214,9 +3214,9 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(source).toContain("touchAction: 'pan-x'"); // The pane and the scrollport must come from the same expression, or a // pane stops being exactly one screen and every snap offset drifts. - const paneBlockSize = /blockSize: dateInputTouchGeometry\.paneBlockSize/g; - expect(source.match(paneBlockSize)).toHaveLength(1); - expect(read('MonthPane.tsx').match(paneBlockSize)).toHaveLength(1); + expect( + source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g), + ).toHaveLength(2); }); it('under a finger: the month/year toggle leaves the calendar\u2019s days alone', () => { @@ -3242,18 +3242,19 @@ describe('DateInput — scroll CSS (definition-level)', () => { // pane stops being exactly one screen. const paneBlockSize = /blockSize: dateInputTouchGeometry\.paneBlockSize/g; expect(source.match(paneBlockSize)).toHaveLength(1); - expect(read('MonthPane.tsx').match(paneBlockSize)).toHaveLength(1); + expect(read('MonthScroller.tsx').match(paneBlockSize)).toHaveLength(2); }); it('positions panes with logical properties, so RTL mirrors', () => { - const source = read('MonthPane.tsx'); - // Scoped to the style objects. The CSS must stay logical. Physical `left` - // would lay the months out the same in both directions while the scroll - // math mirrored. Under RTL the two would disagree. + const source = read('MonthScroller.tsx'); + // Scoped to the style objects: `scrollTo({left})` is the DOM API. The CSS + // must stay logical. Physical `left` would lay the months out the same in + // both directions while the scroll math mirrored. Under RTL the two would + // disagree. const styles = source .slice( source.indexOf('const styles = stylex.create('), - source.indexOf('export interface MonthPaneProps'), + source.indexOf('export interface MonthScrollerHandle'), ) // Comments explain the rule ("insetInlineStart, not left") and would // otherwise trip it. @@ -3291,7 +3292,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(theme.length).toBeGreaterThan(0); // And the touch pane carries all of it, in its one object. - const touch = declarations(read('MonthPane.tsx'), 'dayOutside'); + const touch = declarations(read('MonthScroller.tsx'), 'dayOutside'); expect(touch).toEqual(expect.arrayContaining([...structural, ...theme])); }); @@ -3311,7 +3312,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { it('fades disabled days as the desktop does, rather than recolouring them', () => { const desktop = read('../Calendar/styles.ts'); const theme = declarationsIn(desktop, 'dayCellTheme', 'dayDisabled'); - const touch = declarations(read('MonthPane.tsx'), 'dayDisabled'); + const touch = declarations(read('MonthScroller.tsx'), 'dayDisabled'); // Whatever opacity the desktop fades to, this pane fades to the same one. const fade = theme.find(d => d.startsWith('opacity:')); @@ -3334,7 +3335,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { * than the selectable ones beside it. */ it('lets disabled win over outside on a spilled day past min/max', () => { - const applied = read('MonthPane.tsx'); + const applied = read('MonthScroller.tsx'); const outside = applied.indexOf('day.isOutside && styles.dayOutside'); const disabled = applied.indexOf('isDisabled && styles.dayDisabled'); expect(outside).toBeGreaterThan(-1); diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx index 0bfb1bc882cd7..0cb2c65a192c9 100644 --- a/packages/core/src/DateInput/MonthPager.tsx +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -31,9 +31,11 @@ import {usePager} from '../hooks/usePager'; import {UIKIT_GESTURES} from '../motion/uikit/gestures'; import {observeResize} from '../utils/sharedResizeObserver'; import {dateInputTouchGeometry} from './tokens.stylex'; -import type {MonthScrollerProps} from './MonthScroller'; -import {MonthPane} from './MonthPane'; -import {useMonthGridFocus} from './useMonthGridFocus'; +import { + MonthPane, + useMonthGridFocus, + type MonthScrollerProps, +} from './MonthScroller'; import {useOwnScrollGesture} from './useOwnScrollGesture'; import {clampIndex, paneWindow, rowsIn} from './monthGeometry'; diff --git a/packages/core/src/DateInput/MonthPane.tsx b/packages/core/src/DateInput/MonthPane.tsx deleted file mode 100644 index 9bf53d7c0b0ad..0000000000000 --- a/packages/core/src/DateInput/MonthPane.tsx +++ /dev/null @@ -1,383 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file MonthPane.tsx - * @input A month, the selection, the focused day and the day handlers - * @output MonthPane: one month as a six-row grid of day buttons; and - * monthGridKeyTarget: the day a grid key moves the focus to - * @position Internal leaf shared by MonthScroller.tsx and MonthPager.tsx - * - * SYNC: When modified, update DateInputTouch.test.tsx. - */ - -import {memo, useMemo} from 'react'; -import * as stylex from '@stylexjs/stylex'; -import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; -import {useLocale} from '../i18n/useLocale'; -import { - colorVars, - radiusVars, - fontWeightVars, - typeScaleVars, - durationVars, - borderVars, -} from '../theme/tokens.stylex'; -import {focusOutlineStyles} from '../utils/focusOutline.stylex'; -import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; -import { - plainDateAddDays, - plainDateDayOfWeek, - plainDateFormatDayNumber, - plainDateIsEqual, - plainDateToISO, - DATE_FORMAT_WITH_WEEKDAY, -} from '../utils/plainDate'; -import { - type CalendarSystem, - calendarShiftMonths, - calendarShiftYears, - calendarYearMonthOf, - formatCalendarDate, - formatCalendarMonthLabel, - isInCalendarMonth, -} from '../utils/calendarSystem'; -import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; -import type {MonthModel} from './monthGeometry'; - -const DAY_SIZE = dateInputTouchSizes.daySize; - -const styles = stylex.create({ - pane: { - position: 'absolute', - insetBlock: 0, - blockSize: dateInputTouchGeometry.paneBlockSize, - display: 'grid', - gridTemplateColumns: 'repeat(7, 1fr)', - gridTemplateRows: dateInputTouchGeometry.paneRows, - }, - row: { - display: 'contents', - }, - cell: { - position: 'relative', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - day: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: '100%', - blockSize: '100%', - minInlineSize: DAY_SIZE, - minBlockSize: DAY_SIZE, - padding: 0, - borderWidth: 0, - borderStyle: 'none', - backgroundColor: 'transparent', - color: colorVars['--color-text-primary'], - fontSize: typeScaleVars['--text-body-size'], - fontWeight: fontWeightVars['--font-weight-normal'], - cursor: { - default: 'pointer', - ':is(:disabled,[aria-disabled="true"])': 'default', - }, - // A tap on a 44px target should not also select the number inside it. - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - }, - /** - * The circular hit/selection puck inside the tap target. Sizing the puck - * rather than the button keeps the touch target at the full cell while the - * visual stays a tidy circle. - */ - puck: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - inlineSize: `calc(${DAY_SIZE} - 8px)`, - blockSize: `calc(${DAY_SIZE} - 8px)`, - borderRadius: radiusVars['--radius-full'], - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderColor: 'transparent', - backgroundColor: 'transparent', - transitionProperty: 'background-color, color, border-color', - transitionDuration: durationVars['--duration-fast'], - }, - puckHoverable: { - // Two guards, for two different failure modes. - // - // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the - // last tapped day until something else is tapped. - // - // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a - // disabled control's events, not its hover styling, so an unguarded - // :hover tints a day you cannot pick. The disabled state sits on the - // button, and this puck is the span inside it. So the call site's gate - // does the work, and this guard holds if the styles move to the button. - backgroundColor: { - default: 'transparent', - '@media (hover: hover)': { - default: 'transparent', - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-overlay-hover'], - }, - }, - }, - puckToday: { - borderColor: colorVars['--color-border-emphasized'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - puckSelected: { - backgroundColor: { - default: colorVars['--color-accent'], - // Holds the accent through hover, overriding puckHoverable's tint. - // Same guarded selector, so the two have equal specificity and - // application order decides — which is what puts this one on top. - ':hover:where(:not(:disabled,[aria-disabled="true"]))': - colorVars['--color-accent'], - }, - borderColor: colorVars['--color-accent'], - color: colorVars['--color-on-accent'], - fontWeight: fontWeightVars['--font-weight-semibold'], - }, - /** - * A day belonging to the month either side of this pane. It is muted and - * unselectable, as in Calendar: it shows where the month ends. A tap out of - * the pane would move the calendar under the thumb. - * - * It takes no tab stop: `tabbableISO` names only a date in its own pane's - * month. So a date never has two tab stops. - */ - dayOutside: { - // Calendar's spill-day treatment: the secondary text colour and half - // opacity, so these days read as background, not as dimmer choices. - color: colorVars['--color-text-secondary'], - opacity: 0.5, - }, - dayDisabled: { - // Calendar's disabled treatment: it fades the day's own colour to 0.3. - // So a disabled in-month day and a disabled spill day stay a step apart, - // and both read lighter than an enabled spill day. - opacity: 0.3, - cursor: 'default', - }, -}); - -const dynamic = stylex.create({ - // insetInlineStart, not left: under RTL the panes lay out from the right, - // and the scroller and the pager mirror with them. - pane: (insetInlineStart: number, inlineSize: number) => ({ - insetInlineStart: `${insetInlineStart}px`, - inlineSize: `${inlineSize}px`, - }), -}); - -export interface MonthPaneProps { - /** The calendar's months, by index. */ - monthModel: MonthModel; - monthIndex: number; - insetInlineStart: number; - inlineSize: number; - selectedDate: PlainDate | null; - focusedDate: PlainDate | null; - /** Today, or `null` while it is not known yet (server render, hydration). */ - today: PlainDate | null; - isDateDisabled: (date: PlainDate) => boolean; - weekStartsOn: DayOfWeek; - onSelect: (value: ISODateString) => void; - onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; - onDayFocus: (date: PlainDate) => void; - /** - * Whether one of this pane's days takes a tab stop. The pager gives it only - * to the month it shows. A neighbour is mounted off-screen, and Tab into it - * would scroll the clipped viewport under the track. - */ - hasTabStop: boolean; - /** The surface's own pane style: the scroller's snap or the pager's layer. */ - paneStyle?: stylex.StyleXStyles; -} - -/** - * One month, as a six-row grid. - * - * It has no `role="columnheader"` row: the weekday names are one sticky row - * outside the scroller or the pager, so they never scroll away. Each day - * carries its weekday in its accessible name instead. - */ -export const MonthPane = memo(function MonthPane({ - monthModel, - monthIndex, - insetInlineStart, - inlineSize, - selectedDate, - focusedDate, - today, - isDateDisabled, - weekStartsOn, - onSelect, - onDayKeyDown, - onDayFocus, - hasTabStop, - paneStyle, -}: MonthPaneProps) { - const locale = useLocale(); - const {calendar} = monthModel; - const month = useMemo( - () => monthModel.monthAt(monthIndex), - [monthModel, monthIndex], - ); - const {weeks} = useCalendarDays({ - ...calendarYearMonthOf(calendar, month), - calendar, - weekStartsOn, - // Always six rows: a variable grid would make pane heights differ, and - // with them every snap offset below this month. - hasVariableRowCount: false, - }); - - const monthLabel = formatCalendarMonthLabel(calendar, month, locale); - - // One day per pane is tab-reachable, so Tab moves through the picker, not - // through 42 buttons. It is the focused day, else the selection, else the - // first of the month. - const tabbableISO = (() => { - if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { - return plainDateToISO(focusedDate); - } - if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { - return plainDateToISO(selectedDate); - } - return plainDateToISO(month.start); - })(); - - return ( - <div - role="grid" - aria-label={monthLabel} - data-month={monthLabel} - {...stylex.props( - styles.pane, - paneStyle, - dynamic.pane(insetInlineStart, inlineSize), - )}> - {weeks.map(week => ( - <div key={week[0].iso} role="row" {...stylex.props(styles.row)}> - {week.map(day => { - // A spilled day is context, not a choice, as in Calendar's - // `effectivelyDisabled`. So a date borrowed from a neighbouring - // month carries no ring or puck in the pane that only shows it. - const isDisabled = day.isOutside || isDateDisabled(day.date); - const isSelected = - !day.isOutside && - selectedDate != null && - plainDateIsEqual(day.date, selectedDate); - const isToday = - !day.isOutside && - today != null && - plainDateIsEqual(day.date, today); - - return ( - <div - key={day.iso} - role="gridcell" - aria-selected={isSelected || undefined} - {...stylex.props(styles.cell)}> - <button - type="button" - data-date={day.iso} - data-outside={day.isOutside ? '' : undefined} - tabIndex={hasTabStop && day.iso === tabbableISO ? 0 : -1} - aria-label={formatCalendarDate( - calendar, - day.date, - DATE_FORMAT_WITH_WEEKDAY, - locale, - )} - aria-disabled={isDisabled || undefined} - aria-current={isToday ? 'date' : undefined} - onClick={() => { - if (!isDisabled) { - onSelect(day.iso); - } - }} - onFocus={() => onDayFocus(day.date)} - onKeyDown={event => onDayKeyDown(event, day.date)} - {...stylex.props( - styles.day, - focusOutlineStyles.focusVisible, - // Outside first, so disabled wins where both apply. A - // spilled day past the range must not look more available - // than the in-month days beside it. - day.isOutside && styles.dayOutside, - isDisabled && styles.dayDisabled, - )}> - <span - {...stylex.props( - styles.puck, - !isDisabled && styles.puckHoverable, - isToday && styles.puckToday, - isSelected && styles.puckSelected, - )}> - {plainDateFormatDayNumber(day.dayNumber, locale)} - </span> - </button> - </div> - ); - })} - </div> - ))} - </div> - ); -}); - -MonthPane.displayName = 'MonthPane'; - -/** - * The day a grid key moves the focus to from `date`, or null for any other - * key. The vocabulary is the APG grid's: the arrows follow the reading - * direction, and Home and End reach the ends of the shown week. PageUp and - * PageDown move a month of the calendar, and with Shift a year, as Calendar - * does. - */ -export function monthGridKeyTarget( - event: React.KeyboardEvent, - date: PlainDate, - { - calendar, - isRTL, - weekStartsOn, - }: {calendar: CalendarSystem; isRTL: boolean; weekStartsOn: DayOfWeek}, -): PlainDate | null { - const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; - const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; - const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; - switch (event.key) { - case backward: - return plainDateAddDays(date, -1); - case forward: - return plainDateAddDays(date, 1); - case 'ArrowUp': - return plainDateAddDays(date, -7); - case 'ArrowDown': - return plainDateAddDays(date, 7); - case 'Home': - return plainDateAddDays(date, -column); - case 'End': - return plainDateAddDays(date, 6 - column); - case 'PageUp': - return event.shiftKey - ? calendarShiftYears(calendar, date, -1) - : calendarShiftMonths(calendar, date, -1); - case 'PageDown': - return event.shiftKey - ? calendarShiftYears(calendar, date, 1) - : calendarShiftMonths(calendar, date, 1); - default: - return null; - } -} diff --git a/packages/core/src/DateInput/MonthScroller.tsx b/packages/core/src/DateInput/MonthScroller.tsx index 3c4c717593c01..f889ed2ac90f6 100644 --- a/packages/core/src/DateInput/MonthScroller.tsx +++ b/packages/core/src/DateInput/MonthScroller.tsx @@ -6,8 +6,10 @@ * @file MonthScroller.tsx * @input A calendar's month model, month bounds, selected value, constraints, * week start - * @output Exports MonthScroller and MonthScrollerHandle - * @position Internal component; consumed by TouchDateField.tsx + * @output Exports MonthScroller and MonthScrollerHandle, and the month grid + * MonthPager shares: MonthPane, monthGridKeyTarget and useMonthGridFocus + * @position Internal component; consumed by TouchDateField.tsx. MonthPager.tsx + * renders its MonthPane and uses its useMonthGridFocus. * * The continuous surface: months stacked vertically in one scroller, each pane * exactly as tall as the scrollport and snapped to its start. Two consequences @@ -32,25 +34,55 @@ */ import { + memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, + useMemo, useState, + type KeyboardEvent, + type RefObject, } from 'react'; import {flushSync} from 'react-dom'; import * as stylex from '@stylexjs/stylex'; +import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; import {useDirection} from '../i18n/useDirection'; +import {useLocale} from '../i18n/useLocale'; +import { + colorVars, + radiusVars, + fontWeightVars, + typeScaleVars, + durationVars, + borderVars, +} from '../theme/tokens.stylex'; +import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import type {DayOfWeek, ISODateString, PlainDate} from '../utils/dateTypes'; +import { + plainDateAddDays, + plainDateDayOfWeek, + plainDateFormatDayNumber, + plainDateIsEqual, + plainDateToISO, + DATE_FORMAT_WITH_WEEKDAY, +} from '../utils/plainDate'; import { playReducedMotionDip, reducedMotionFade, type ReducedMotionDip, } from '../motion/reducedMotion'; -import {dateInputTouchGeometry} from './tokens.stylex'; -import {MonthPane} from './MonthPane'; -import {useMonthGridFocus} from './useMonthGridFocus'; +import { + type CalendarSystem, + calendarShiftMonths, + calendarShiftYears, + calendarYearMonthOf, + formatCalendarDate, + formatCalendarMonthLabel, + isInCalendarMonth, +} from '../utils/calendarSystem'; +import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; import {useOwnScrollGesture} from './useOwnScrollGesture'; import {useScrollSettle, useScrollSteer} from './useScrollSettle'; import { @@ -73,6 +105,8 @@ const OVERSCAN = 3; /** Every listener on the scroller is passive; one options object serves all. */ const PASSIVE: AddEventListenerOptions = {passive: true}; +const DAY_SIZE = dateInputTouchSizes.daySize; + const styles = stylex.create({ scroller: { position: 'relative', @@ -109,12 +143,135 @@ const styles = stylex.create({ // ahead" should be one throw, not three flicks. Momentum carrying past // several panes is why OVERSCAN is what it is. }, + pane: { + position: 'absolute', + insetBlock: 0, + blockSize: dateInputTouchGeometry.paneBlockSize, + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gridTemplateRows: dateInputTouchGeometry.paneRows, + }, + row: { + display: 'contents', + }, + cell: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + day: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: '100%', + blockSize: '100%', + minInlineSize: DAY_SIZE, + minBlockSize: DAY_SIZE, + padding: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + fontSize: typeScaleVars['--text-body-size'], + fontWeight: fontWeightVars['--font-weight-normal'], + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + // A tap on a 44px target should not also select the number inside it. + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }, + /** + * The circular hit/selection puck inside the tap target. Sizing the puck + * rather than the button keeps the touch target at the full cell while the + * visual stays a tidy circle. + */ + puck: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + inlineSize: `calc(${DAY_SIZE} - 8px)`, + blockSize: `calc(${DAY_SIZE} - 8px)`, + borderRadius: radiusVars['--radius-full'], + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: 'transparent', + backgroundColor: 'transparent', + transitionProperty: 'background-color, color, border-color', + transitionDuration: durationVars['--duration-fast'], + }, + puckHoverable: { + // Two guards, for two different failure modes. + // + // `@media (hover: hover)`: on a touch screen a :hover tint sticks to the + // last tapped day until something else is tapped. + // + // `:where(:not(:disabled,[aria-disabled="true"]))`: a browser suppresses a + // disabled control's events, not its hover styling, so an unguarded + // :hover tints a day you cannot pick. The disabled state sits on the + // button, and this puck is the span inside it. So the call site's gate + // does the work, and this guard holds if the styles move to the button. + backgroundColor: { + default: 'transparent', + '@media (hover: hover)': { + default: 'transparent', + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-overlay-hover'], + }, + }, + }, + puckToday: { + borderColor: colorVars['--color-border-emphasized'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + puckSelected: { + backgroundColor: { + default: colorVars['--color-accent'], + // Holds the accent through hover, overriding puckHoverable's tint. + // Same guarded selector, so the two have equal specificity and + // application order decides — which is what puts this one on top. + ':hover:where(:not(:disabled,[aria-disabled="true"]))': + colorVars['--color-accent'], + }, + borderColor: colorVars['--color-accent'], + color: colorVars['--color-on-accent'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + /** + * A day belonging to the month either side of this pane. It is muted and + * unselectable, as in Calendar: it shows where the month ends. A tap out of + * the pane would move the calendar under the thumb. + * + * It takes no tab stop: `tabbableISO` names only a date in its own pane's + * month. So a date never has two tab stops. + */ + dayOutside: { + // Calendar's spill-day treatment: the secondary text colour and half + // opacity, so these days read as background, not as dimmer choices. + color: colorVars['--color-text-secondary'], + opacity: 0.5, + }, + dayDisabled: { + // Calendar's disabled treatment: it fades the day's own colour to 0.3. + // So a disabled in-month day and a disabled spill day stay a step apart, + // and both read lighter than an enabled spill day. + opacity: 0.3, + cursor: 'default', + }, }); const dynamic = stylex.create({ spacer: (inlineSize: number) => ({ inlineSize: `${inlineSize}px`, }), + // insetInlineStart, not left: under RTL the panes lay out from the right, + // and the scroller and the pager mirror with them. + pane: (insetInlineStart: number, inlineSize: number) => ({ + insetInlineStart: `${insetInlineStart}px`, + inlineSize: `${inlineSize}px`, + }), }); /** The handle of MonthScroller and of MonthPager, its touch counterpart. */ @@ -630,3 +787,321 @@ export function MonthScroller({ } MonthScroller.displayName = 'MonthScroller'; + +export interface MonthPaneProps { + /** The calendar's months, by index. */ + monthModel: MonthModel; + monthIndex: number; + insetInlineStart: number; + inlineSize: number; + selectedDate: PlainDate | null; + focusedDate: PlainDate | null; + /** Today, or `null` while it is not known yet (server render, hydration). */ + today: PlainDate | null; + isDateDisabled: (date: PlainDate) => boolean; + weekStartsOn: DayOfWeek; + onSelect: (value: ISODateString) => void; + onDayKeyDown: (event: React.KeyboardEvent, date: PlainDate) => void; + onDayFocus: (date: PlainDate) => void; + /** + * Whether one of this pane's days takes a tab stop. The pager gives it only + * to the month it shows. A neighbour is mounted off-screen, and Tab into it + * would scroll the clipped viewport under the track. + */ + hasTabStop: boolean; + /** The surface's own pane style: the scroller's snap or the pager's layer. */ + paneStyle?: stylex.StyleXStyles; +} + +/** + * One month, as a six-row grid. + * + * It has no `role="columnheader"` row: the weekday names are one sticky row + * outside the scroller or the pager, so they never scroll away. Each day + * carries its weekday in its accessible name instead. + */ +export const MonthPane = memo(function MonthPane({ + monthModel, + monthIndex, + insetInlineStart, + inlineSize, + selectedDate, + focusedDate, + today, + isDateDisabled, + weekStartsOn, + onSelect, + onDayKeyDown, + onDayFocus, + hasTabStop, + paneStyle, +}: MonthPaneProps) { + const locale = useLocale(); + const {calendar} = monthModel; + const month = useMemo( + () => monthModel.monthAt(monthIndex), + [monthModel, monthIndex], + ); + const {weeks} = useCalendarDays({ + ...calendarYearMonthOf(calendar, month), + calendar, + weekStartsOn, + // Always six rows: a variable grid would make pane heights differ, and + // with them every snap offset below this month. + hasVariableRowCount: false, + }); + + const monthLabel = formatCalendarMonthLabel(calendar, month, locale); + + // One day per pane is tab-reachable, so Tab moves through the picker, not + // through 42 buttons. It is the focused day, else the selection, else the + // first of the month. + const tabbableISO = (() => { + if (focusedDate != null && isInCalendarMonth(focusedDate, month)) { + return plainDateToISO(focusedDate); + } + if (selectedDate != null && isInCalendarMonth(selectedDate, month)) { + return plainDateToISO(selectedDate); + } + return plainDateToISO(month.start); + })(); + + return ( + <div + role="grid" + aria-label={monthLabel} + data-month={monthLabel} + {...stylex.props( + styles.pane, + paneStyle, + dynamic.pane(insetInlineStart, inlineSize), + )}> + {weeks.map(week => ( + <div key={week[0].iso} role="row" {...stylex.props(styles.row)}> + {week.map(day => { + // A spilled day is context, not a choice, as in Calendar's + // `effectivelyDisabled`. So a date borrowed from a neighbouring + // month carries no ring or puck in the pane that only shows it. + const isDisabled = day.isOutside || isDateDisabled(day.date); + const isSelected = + !day.isOutside && + selectedDate != null && + plainDateIsEqual(day.date, selectedDate); + const isToday = + !day.isOutside && + today != null && + plainDateIsEqual(day.date, today); + + return ( + <div + key={day.iso} + role="gridcell" + aria-selected={isSelected || undefined} + {...stylex.props(styles.cell)}> + <button + type="button" + data-date={day.iso} + data-outside={day.isOutside ? '' : undefined} + tabIndex={hasTabStop && day.iso === tabbableISO ? 0 : -1} + aria-label={formatCalendarDate( + calendar, + day.date, + DATE_FORMAT_WITH_WEEKDAY, + locale, + )} + aria-disabled={isDisabled || undefined} + aria-current={isToday ? 'date' : undefined} + onClick={() => { + if (!isDisabled) { + onSelect(day.iso); + } + }} + onFocus={() => onDayFocus(day.date)} + onKeyDown={event => onDayKeyDown(event, day.date)} + {...stylex.props( + styles.day, + focusOutlineStyles.focusVisible, + // Outside first, so disabled wins where both apply. A + // spilled day past the range must not look more available + // than the in-month days beside it. + day.isOutside && styles.dayOutside, + isDisabled && styles.dayDisabled, + )}> + <span + {...stylex.props( + styles.puck, + !isDisabled && styles.puckHoverable, + isToday && styles.puckToday, + isSelected && styles.puckSelected, + )}> + {plainDateFormatDayNumber(day.dayNumber, locale)} + </span> + </button> + </div> + ); + })} + </div> + ))} + </div> + ); +}); + +MonthPane.displayName = 'MonthPane'; + +/** + * The day a grid key moves the focus to from `date`, or null for any other + * key. The vocabulary is the APG grid's: the arrows follow the reading + * direction, and Home and End reach the ends of the shown week. PageUp and + * PageDown move a month of the calendar, and with Shift a year, as Calendar + * does. + */ +export function monthGridKeyTarget( + event: React.KeyboardEvent, + date: PlainDate, + { + calendar, + isRTL, + weekStartsOn, + }: {calendar: CalendarSystem; isRTL: boolean; weekStartsOn: DayOfWeek}, +): PlainDate | null { + const column = (plainDateDayOfWeek(date) - weekStartsOn + 7) % 7; + const forward = isRTL ? 'ArrowLeft' : 'ArrowRight'; + const backward = isRTL ? 'ArrowRight' : 'ArrowLeft'; + switch (event.key) { + case backward: + return plainDateAddDays(date, -1); + case forward: + return plainDateAddDays(date, 1); + case 'ArrowUp': + return plainDateAddDays(date, -7); + case 'ArrowDown': + return plainDateAddDays(date, 7); + case 'Home': + return plainDateAddDays(date, -column); + case 'End': + return plainDateAddDays(date, 6 - column); + case 'PageUp': + return event.shiftKey + ? calendarShiftYears(calendar, date, -1) + : calendarShiftMonths(calendar, date, -1); + case 'PageDown': + return event.shiftKey + ? calendarShiftYears(calendar, date, 1) + : calendarShiftMonths(calendar, date, 1); + default: + return null; + } +} + +export interface MonthGridFocusOptions { + /** The calendar's months, by index. */ + monthModel: MonthModel; + minMonthIndex: number; + maxMonthIndex: number; + weekStartsOn: DayOfWeek; + /** Moves the grid to a month, as its arrows do. */ + scrollToMonth: (monthIndex: number, behavior?: ScrollBehavior) => void; + /** The grid's month report, read through a ref. */ + onVisibleMonthChangeRef: RefObject<(monthIndex: number) => void>; +} + +export interface MonthGridFocus { + /** The day that has the keyboard focus, or null before the first key. */ + focusedDate: PlainDate | null; + /** The days' key handler. */ + handleKeyDown: (event: KeyboardEvent, date: PlainDate) => void; + /** The days' focus handler. */ + handleDayFocus: (date: PlainDate) => void; +} + +/** + * The month grid's keyboard focus. It moves by date, not by cell index, so it + * crosses months as a calendar does, and the grid follows it to the new month. + * Only a key press moves DOM focus, so a drag never takes it. + */ +export function useMonthGridFocus( + gridRef: RefObject<HTMLElement | null>, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, + }: MonthGridFocusOptions, +): MonthGridFocus { + const [focusedDate, setFocusedDate] = useState<PlainDate | null>(null); + const shouldRestoreFocusRef = useRef(false); + + const moveFocusTo = useCallback( + (from: PlainDate, next: PlainDate) => { + const nextIndex = monthModel.indexOf(next); + if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { + return; + } + shouldRestoreFocusRef.current = true; + setFocusedDate(next); + if (nextIndex !== monthModel.indexOf(from)) { + scrollToMonth(nextIndex, 'smooth'); + // Neither grid reports a move it was told to make, so the month is + // reported here. + onVisibleMonthChangeRef.current(nextIndex); + } + }, + [ + monthModel, + minMonthIndex, + maxMonthIndex, + scrollToMonth, + onVisibleMonthChangeRef, + ], + ); + + const isRTL = useDirection() === 'rtl'; + const handleKeyDown = useCallback( + (event: KeyboardEvent, date: PlainDate) => { + const next = monthGridKeyTarget(event, date, { + calendar: monthModel.calendar, + isRTL, + weekStartsOn, + }); + if (next != null) { + event.preventDefault(); + moveFocusTo(date, next); + } + }, + [isRTL, monthModel, moveFocusTo, weekStartsOn], + ); + + const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); + // Syncs DOM focus with the focused day. The day may sit in a pane mounted + // only this frame: it takes focus once it exists. + useEffect(() => { + if (!shouldRestoreFocusRef.current || focusedISO == null) { + return; + } + // A date can be mounted twice: as a real day in its pane and as a + // disabled spill copy in a neighbour. Only the real day may take focus, + // or Enter and Space would select nothing. + const target = gridRef.current?.querySelector<HTMLElement>( + `[data-date="${focusedISO}"]:not([data-outside])`, + ); + if (target != null) { + shouldRestoreFocusRef.current = false; + // preventScroll: the grid already moves to this month. A browser + // scroll-into-view would fight the scroller's snap, or shift the + // pager's viewport under its track. + target.focus({preventScroll: true}); + } + }, [focusedISO, gridRef]); + + // A focus event fires for the programmatic focus above too; only take the + // date when it is actually a different day. + const handleDayFocus = useCallback((date: PlainDate) => { + setFocusedDate(previous => + previous != null && plainDateIsEqual(previous, date) ? previous : date, + ); + }, []); + + return {focusedDate, handleKeyDown, handleDayFocus}; +} diff --git a/packages/core/src/DateInput/useMonthGridFocus.ts b/packages/core/src/DateInput/useMonthGridFocus.ts deleted file mode 100644 index 596f85961df4c..0000000000000 --- a/packages/core/src/DateInput/useMonthGridFocus.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file useMonthGridFocus.ts - * @input The month grid's element, its month range and week start, and how - * the grid moves to a month and reports one - * @output useMonthGridFocus: the focused day, the days' key handler and their - * focus handler - * @position Internal hook shared by MonthScroller.tsx and MonthPager.tsx - */ - -import { - useCallback, - useEffect, - useRef, - useState, - type KeyboardEvent, - type RefObject, -} from 'react'; -import {useDirection} from '../i18n/useDirection'; -import type {DayOfWeek, PlainDate} from '../utils/dateTypes'; -import {plainDateIsEqual, plainDateToISO} from '../utils/plainDate'; -import {monthGridKeyTarget} from './MonthPane'; -import type {MonthModel} from './monthGeometry'; - -export interface MonthGridFocusOptions { - /** The calendar's months, by index. */ - monthModel: MonthModel; - minMonthIndex: number; - maxMonthIndex: number; - weekStartsOn: DayOfWeek; - /** Moves the grid to a month, as its arrows do. */ - scrollToMonth: (monthIndex: number, behavior?: ScrollBehavior) => void; - /** The grid's month report, read through a ref. */ - onVisibleMonthChangeRef: RefObject<(monthIndex: number) => void>; -} - -export interface MonthGridFocus { - /** The day that has the keyboard focus, or null before the first key. */ - focusedDate: PlainDate | null; - /** The days' key handler. */ - handleKeyDown: (event: KeyboardEvent, date: PlainDate) => void; - /** The days' focus handler. */ - handleDayFocus: (date: PlainDate) => void; -} - -/** - * The month grid's keyboard focus. It moves by date, not by cell index, so it - * crosses months as a calendar does, and the grid follows it to the new month. - * Only a key press moves DOM focus, so a drag never takes it. - */ -export function useMonthGridFocus( - gridRef: RefObject<HTMLElement | null>, - { - monthModel, - minMonthIndex, - maxMonthIndex, - weekStartsOn, - scrollToMonth, - onVisibleMonthChangeRef, - }: MonthGridFocusOptions, -): MonthGridFocus { - const [focusedDate, setFocusedDate] = useState<PlainDate | null>(null); - const shouldRestoreFocusRef = useRef(false); - - const moveFocusTo = useCallback( - (from: PlainDate, next: PlainDate) => { - const nextIndex = monthModel.indexOf(next); - if (nextIndex < minMonthIndex || nextIndex > maxMonthIndex) { - return; - } - shouldRestoreFocusRef.current = true; - setFocusedDate(next); - if (nextIndex !== monthModel.indexOf(from)) { - scrollToMonth(nextIndex, 'smooth'); - // Neither grid reports a move it was told to make, so the month is - // reported here. - onVisibleMonthChangeRef.current(nextIndex); - } - }, - [ - monthModel, - minMonthIndex, - maxMonthIndex, - scrollToMonth, - onVisibleMonthChangeRef, - ], - ); - - const isRTL = useDirection() === 'rtl'; - const handleKeyDown = useCallback( - (event: KeyboardEvent, date: PlainDate) => { - const next = monthGridKeyTarget(event, date, { - calendar: monthModel.calendar, - isRTL, - weekStartsOn, - }); - if (next != null) { - event.preventDefault(); - moveFocusTo(date, next); - } - }, - [isRTL, monthModel, moveFocusTo, weekStartsOn], - ); - - const focusedISO = focusedDate == null ? null : plainDateToISO(focusedDate); - // Syncs DOM focus with the focused day. The day may sit in a pane mounted - // only this frame: it takes focus once it exists. - useEffect(() => { - if (!shouldRestoreFocusRef.current || focusedISO == null) { - return; - } - // A date can be mounted twice: as a real day in its pane and as a - // disabled spill copy in a neighbour. Only the real day may take focus, - // or Enter and Space would select nothing. - const target = gridRef.current?.querySelector<HTMLElement>( - `[data-date="${focusedISO}"]:not([data-outside])`, - ); - if (target != null) { - shouldRestoreFocusRef.current = false; - // preventScroll: the grid already moves to this month. A browser - // scroll-into-view would fight the scroller's snap, or shift the - // pager's viewport under its track. - target.focus({preventScroll: true}); - } - }, [focusedISO, gridRef]); - - // A focus event fires for the programmatic focus above too; only take the - // date when it is actually a different day. - const handleDayFocus = useCallback((date: PlainDate) => { - setFocusedDate(previous => - previous != null && plainDateIsEqual(previous, date) ? previous : date, - ); - }, []); - - return {focusedDate, handleKeyDown, handleDayFocus}; -} From 1eba81bd848e3f1a669316c030772cada049a5a6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 00:36:14 -0800 Subject: [PATCH 33/40] refactor(DateInput): one hook for the month/year toggle useMonthYearToggle replaces useFadeInert and useChevronDip: it swaps inert at the fade's end, hands focus over and dips the chevron, and `toggle` does all three. The wheels no longer lay out ahead of the toggle, and the chevron's quarter turn is a literal in both fields. --- .../calendar-toggle-uidatepicker-timing.md | 1 - .../components/DateInput/DateInput.spec.md | 2 +- .../src/DateInput/DateInputTouch.test.tsx | 43 ++--- .../core/src/DateInput/TouchDateField.tsx | 69 +++---- ...onthYearToggle.interaction.browser.spec.ts | 81 ++------ .../core/src/DateInput/datePickerTouch.ts | 4 +- .../src/DateInput/monthYearToggle.stylex.ts | 28 --- .../core/src/DateInput/useChevronDip.test.tsx | 106 ----------- packages/core/src/DateInput/useChevronDip.ts | 89 --------- ...t.test.tsx => useMonthYearToggle.test.tsx} | 143 +++++++++++--- ...{useFadeInert.ts => useMonthYearToggle.ts} | 175 +++++++++++++----- .../src/DateInput/useWheelsWarmUp.test.tsx | 76 -------- .../core/src/DateInput/useWheelsWarmUp.ts | 74 -------- .../DateTimeInput/DateTimeInputTouch.test.tsx | 2 +- .../src/DateTimeInput/TouchDateTimeField.tsx | 62 ++----- 15 files changed, 330 insertions(+), 625 deletions(-) delete mode 100644 packages/core/src/DateInput/monthYearToggle.stylex.ts delete mode 100644 packages/core/src/DateInput/useChevronDip.test.tsx delete mode 100644 packages/core/src/DateInput/useChevronDip.ts rename packages/core/src/DateInput/{useFadeInert.test.tsx => useMonthYearToggle.test.tsx} (68%) rename packages/core/src/DateInput/{useFadeInert.ts => useMonthYearToggle.ts} (68%) delete mode 100644 packages/core/src/DateInput/useWheelsWarmUp.test.tsx delete mode 100644 packages/core/src/DateInput/useWheelsWarmUp.ts diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index d93ec0062d78a..ad25855bcf34a 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -5,7 +5,6 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: - The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. -- The hidden month/year wheels are laid out once the sheet settles, so a toggle after that does not lay them out. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index c2e8805164ff7..b623f9734876f 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -423,7 +423,7 @@ clear-glyph target of its own. | FR22 | `DateInputTouch.test.tsx` "moves focus to the other arrow when the focused arrow reaches the end of the range and hides" and "moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus" | Sheet month arrows at the first and last month of a range | Focus left on a hidden arrow, or dropped to the document body, fails. | `audit:DateInput/behavior` | | FR23 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | | FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on the theme’s tokens nearest UIKit’s toggle", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | -| FR25 | `useFadeInert.test.tsx` "makes the outgoing panel inert when its fade ends, never with the toggle", "swaps inert with the toggle with a mouse, fade or not", "moves focus inside the outgoing panel to the incoming one in the toggle’s commit", "leaves focus outside the panels where it is"; `MonthYearToggle.interaction.browser.spec.ts` "a tap on Done during the opening fade does not reach Save beneath it", "DateTimeInput: a tap on the month arrows during the opening fade changes nothing", "DateInput: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends"; `DateInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit", "Done hands focus from the wheels to the calendar in the toggle’s commit"; `DateTimeInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit" | A tap mid-fade on Done, the arrows or a panel; a toggle with focus in either panel; a mouse toggle | A tap that reaches a panel or an incoming action before the fade ends, an outgoing action that still takes taps after the toggle, or focus left in a fading panel fails. | `audit:DateInput/behavior` | +| FR25 | `useMonthYearToggle.test.tsx` "makes the outgoing panel inert when its fade ends, never with the toggle", "swaps inert with the toggle with a mouse, fade or not", "moves focus inside the outgoing panel to the incoming one in the toggle’s commit", "leaves focus outside the panels where it is"; `MonthYearToggle.interaction.browser.spec.ts` "a tap on Done during the opening fade does not reach Save beneath it", "DateTimeInput: a tap on the month arrows during the opening fade changes nothing", "DateInput: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends"; `DateInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit", "Done hands focus from the wheels to the calendar in the toggle’s commit"; `DateTimeInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit" | A tap mid-fade on Done, the arrows or a panel; a toggle with focus in either panel; a mouse toggle | A tap that reaches a panel or an incoming action before the fade ends, an outgoing action that still takes taps after the toggle, or focus left in a fading panel fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 110dd250f4a3a..2e3ff193b6ca7 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -72,7 +72,6 @@ import {IconButton} from '../IconButton'; import type * as IconButtonModule from '../IconButton'; import {MonthYearWheels} from './MonthYearWheels'; import type * as MonthYearWheelsModule from './MonthYearWheels'; -import {SHEET_SETTLE_MS} from './useWheelsWarmUp'; import {MonthPager} from './datePickerTouch'; import type * as DatePickerTouchModule from './datePickerTouch'; @@ -2383,7 +2382,7 @@ describe('DateInput — month/year wheels', () => { it('keeps the header actions and both footer actions out of reach while the toggle fades', async () => { renderAndOpen(); // A running exit on both panels, so their own `inert` waits for the - // fade's end (useFadeInert). + // fade's end (useMonthYearToggle). for (const which of ['calendar', 'wheels'] as const) { (panel(which) as HTMLElement).getAnimations = () => [ @@ -3408,14 +3407,11 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(vi.mocked(useCalendarDays)).not.toHaveBeenCalled(); }); - it('under a finger: the month/year toggle re-renders none of the sheet\u2019s buttons, only the wheels it brings live', async () => { + it('under a finger: the month/year toggle re-renders none of the sheet\u2019s buttons, only the wheels it brings live', () => { // The toggle changes the title's chevron, the panels' classes and the // wheels' `isActive`. Every button keeps its element, so React skips it, // and the wheels render once. renderAndOpen(); - await act(async () => { - await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); - }); vi.mocked(MonthYearWheels).mockClear(); vi.mocked(Button).mockClear(); vi.mocked(IconButton).mockClear(); @@ -3425,16 +3421,13 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(vi.mocked(MonthYearWheels)).toHaveBeenCalledTimes(1); }); - it('under a finger: the arrows the toggle keeps still move focus on at the range end', async () => { + it('under a finger: the arrows the toggle keeps still move focus on at the range end', () => { // The toggle re-renders neither month arrow, so a press after the toggle // runs the arrows' earlier callbacks. They must still move focus off an // arrow that hides. renderAndOpen( <Controlled initial="2026-03-10" min="2026-01-01" max="2026-04-30" />, ); - await act(async () => { - await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); - }); const title = () => document.querySelector<HTMLElement>('[data-title="month-year"]')!; vi.mocked(IconButton).mockClear(); @@ -3450,7 +3443,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(screen.getByRole('button', {name: 'Next month'})).toHaveFocus(); }); - it('under a finger: the month grid\u2019s callback the toggle keeps still moves focus on when a settled month reaches the range end', async () => { + it('under a finger: the month grid\u2019s callback the toggle keeps still moves focus on when a settled month reaches the range end', () => { // The toggle leaves the callback unchanged, so the memoized pager does not // re-render. A month the pager settles on after the toggle reaches the // callback from before it, which must still move focus off the arrow @@ -3458,9 +3451,6 @@ describe('DateInput — scroll CSS (definition-level)', () => { renderAndOpen( <Controlled initial="2026-03-10" min="2026-01-01" max="2026-04-30" />, ); - await act(async () => { - await new Promise(resolve => setTimeout(resolve, SHEET_SETTLE_MS + 500)); - }); const title = () => document.querySelector<HTMLElement>('[data-title="month-year"]')!; const pagerRenders = vi.mocked(MonthPager.type); @@ -3926,9 +3916,10 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_TURN"); // At rest it points along the reading direction (mirrored under RTL, // with the same function list in both states so the turn stays a turn). - expect(chevron).toContain('default: `rotate(-${CHEVRON_TURN})`'); + const turn = datePicker.calendar.monthYearToggle.chevronTurnDegrees; + expect(chevron).toContain(`default: 'rotate(-${turn}deg)'`); expect(chevron).toContain( - '\':is([dir="rtl"] *)\': `scaleX(-1) rotate(-${CHEVRON_TURN})`', + `':is([dir="rtl"] *)': 'scaleX(-1) rotate(-${turn}deg)'`, ); const open = declarations(source, 'titleChevronOpen'); expect(open).toContain("default: 'rotate(0deg)'"); @@ -3969,9 +3960,13 @@ describe('DateInput — scroll CSS (definition-level)', () => { '--duration-medium-min', 225, ]); - // Not in tokens.stylex.ts: that module is a released subpath. - const tokens = read('monthYearToggle.stylex.ts'); - expect(tokens).toContain(`chevronTurn: '${toggle.chevronTurnDegrees}deg'`); + // Both fields write the table's quarter turn as a literal. + for (const field of [ + 'TouchDateField.tsx', + '../DateTimeInput/TouchDateTimeField.tsx', + ]) { + expect(read(field)).toContain(`rotate(-${toggle.chevronTurnDegrees}deg)`); + } }); /** @@ -4093,9 +4088,7 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(declarations(source, 'touchTitleChevronOpen')).toContain( "default: 'rotate(0deg)'", ); - expect(source).toContain( - 'const {ref: chevronRef, dip: dipChevron} = useChevronDip()', - ); + expect(source).toContain('ref={titleRef}'); }); it('follows design:reduced-motion: a mouse at once, a touch screen the policy\u2019s cross-fade, no turn', () => { @@ -4124,11 +4117,9 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(chevron).toContain( "'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s'", ); - // The turn lands at the dip's middle (useChevronDip), not on a delay. + // The turn lands at the dip's middle (useMonthYearToggle), not on a delay. expect(chevron.includes('transitionDelay')).toBe(false); - expect(source).toContain( - 'const {ref: chevronRef, dip: dipChevron} = useChevronDip()', - ); + expect(source).toContain('useMergedRefs(monthYearButtonRef, titleRef)'); }); it('keeps the virtual keyboard down on the touch field', () => { diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index cdc9f2c98e89b..30c4aacfad986 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -126,8 +126,8 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {normalizeDayOfWeek} from '../utils/dateTypes'; import {getWeekdayHeaders} from '../Calendar/getWeekdayHeaderNames'; -import {MonthPager, useChevronDip, useWheelsWarmUp} from './datePickerTouch'; -import {useFadeInert} from './useFadeInert'; +import {MonthPager} from './datePickerTouch'; +import {useMonthYearToggle} from './useMonthYearToggle'; import {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; import {useTouchPresentation} from '../utils/touchPresentation'; import {MonthYearWheels} from './MonthYearWheels'; @@ -150,7 +150,6 @@ const UNKNOWN_TODAY_STAND_IN = plainDateFromISO('1970-01-01'); registerEnglishFallback(english); import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; -import {monthYearToggleMotion} from './monthYearToggle.stylex'; /** * The comfortable minimum tap target on both iOS and Android, honoured by @@ -187,7 +186,6 @@ const TOGGLE_DURATION = durationVars['--duration-medium-min']; const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; const TOGGLE_EXIT = easeVars['--ease-exit']; const TOGGLE_TURN = easeVars['--ease-move']; -const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; const sizeStyles = stylex.create({ sm: { @@ -453,14 +451,15 @@ const styles = stylex.create({ // distance to cover. Same duration as the surface it announces, so the // two land together. Under a finger it is UIDatePicker's chevron. It points along // the reading direction for the calendar, and turns down on the toggle's - // duration and the move curve. + // duration and the move curve. A test holds the quarter turn to the UIKit + // table (datePicker.calendar.monthYearToggle.chevronTurnDegrees). transform: { default: 'none', '@media (pointer: coarse)': { - default: `rotate(-${CHEVRON_TURN})`, + default: 'rotate(-90deg)', // Mirrored under RTL, so it points along the reading direction; the // same function list in both states keeps the turn a turn. - ':is([dir="rtl"] *)': `scaleX(-1) rotate(-${CHEVRON_TURN})`, + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(-90deg)', }, }, transitionProperty: 'transform', @@ -468,8 +467,8 @@ const styles = stylex.create({ default: SWAP_DURATION, '@media (pointer: coarse)': TOGGLE_DURATION, // Under Reduced Motion the chevron does not turn. On a touch screen the - // turn lands at the middle of its opacity dip (useChevronDip), and with - // a mouse at once. + // turn lands at the middle of its opacity dip (useMonthYearToggle), and + // with a mouse at once. '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': '0s', @@ -541,8 +540,8 @@ const styles = stylex.create({ /** * While the month/year toggle cross-fades, a shield over both panels takes * touches, as a UIKit view animation ignores them. `inert` swaps only at - * the fade's end (useFadeInert). A pseudo-element on the body, so showing - * it restyles nothing inside the panels. + * the fade's end (useMonthYearToggle). A pseudo-element on the body, so + * showing it restyles nothing inside the panels. */ bodyFading: { '::after': { @@ -668,12 +667,6 @@ const styles = stylex.create({ reducedMotionFadeConsts.easing, }, }, - // The hidden wheels under a finger once the sheet has settled: laid out, at - // opacity 0, so the toggle's commit only changes their opacity - // (useWheelsWarmUp). Still inert and hidden from assistive technology. - panelOverlayWarm: { - visibility: 'visible', - }, footer: { paddingBlockStart: spacingVars['--spacing-2'], // Same reason as the header: the content box owns the inline inset, so @@ -833,18 +826,20 @@ export function TouchDateField({ isSheetOpen ? sheetBodyRef : inputRef, ); const [isWheelOpen, setIsWheelOpen] = useState(false); - // The panels swap `inert` when the toggle's fade ends (useFadeInert), since - // flipping it restyles a panel's subtree. `aria-hidden` follows the toggle - // at once, and a shield takes touches while the fade runs. + // The panels swap `inert` when the toggle's fade ends (useMonthYearToggle), + // since flipping it restyles a panel's subtree. `aria-hidden` follows the + // toggle at once, and a shield takes touches while the fade runs. const calendarPanelRef = useRef<HTMLDivElement | null>(null); const wheelsPanelRef = useRef<HTMLDivElement | null>(null); const {isTouch} = useTouchPresentation(); - const {isCalendarInert, isWheelsInert, isFading, followFocus} = useFadeInert( - isWheelOpen, - calendarPanelRef, - wheelsPanelRef, - isTouch, - ); + const {isCalendarInert, isWheelsInert, isFading, titleRef, toggle} = + useMonthYearToggle({ + isWheelOpen, + setIsWheelOpen, + isTouch, + calendarRef: calendarPanelRef, + wheelsRef: wheelsPanelRef, + }); // The header's actions and the footer's two leave with the toggle and // arrive with the fade's end. They sit outside the panels' shield, so they // go inert at once. Otherwise a tap on Done during the fade would fall @@ -852,17 +847,13 @@ export function TouchDateField({ const isCalendarActionsInert = isWheelOpen || isCalendarInert; const isWheelsActionsInert = !isWheelOpen || isWheelsInert; const MonthGrid = isTouch ? MonthPager : MonthScroller; - // Once the sheet settles, the hidden wheels are laid out at opacity 0, so - // the toggle itself only changes opacity. - const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); - const {ref: chevronRef, dip: dipChevron} = useChevronDip(); const scrollerHandleRef = useRef<MonthScrollerHandle | null>(null); const previousArrowRef = useRef<HTMLButtonElement | null>(null); const nextArrowRef = useRef<HTMLButtonElement | null>(null); const monthYearButtonRef = useRef<HTMLButtonElement | null>(null); // The title button takes two refs: the chevron's dip reads it, and the // month arrows hand focus to it when both hide. - const titleButtonRef = useMergedRefs(monthYearButtonRef, chevronRef); + const titleButtonRef = useMergedRefs(monthYearButtonRef, titleRef); // Pending focus handoff from the clear button; see handleClear. const clearFocusTimerRef = useRef<number | null>(null); // Releases the focus timer on unmount. @@ -1264,11 +1255,7 @@ export function TouchDateField({ // A live hidden wheel would hold the compositor to the main thread during // the fade. The buttons keep their identity, so React skips them. const closeSheet = useCallback(() => setIsSheetOpen(false), []); - const closeWheels = useCallback(() => { - followFocus(false); - dipChevron(); - setIsWheelOpen(false); - }, [followFocus, dipChevron]); + const closeWheels = useCallback(() => toggle(false), [toggle]); const monthArrowButtons = useMemo( () => ( <> @@ -1385,11 +1372,7 @@ export function TouchDateField({ <button ref={titleButtonRef} type="button" - onClick={() => { - followFocus(!isWheelOpen); - dipChevron(); - setIsWheelOpen(!isWheelOpen); - }} + onClick={() => toggle(!isWheelOpen)} aria-expanded={isWheelOpen} // One string, not a template: the comma and the word order are // the translator's to choose, not English's. @@ -1416,7 +1399,7 @@ export function TouchDateField({ color="secondary" // The transform rides on the Icon itself, not a wrapper span, so // the element a theme can target is the element that moves (and, - // under reduced motion on a touch screen, dips: useChevronDip). + // under reduced motion on a touch screen, dips: useMonthYearToggle). // StyleX's types do not model the touch key's nested RTL // condition, so the styles are cast; they compile as written. xstyle={ @@ -1520,8 +1503,6 @@ export function TouchDateField({ styles.panel, styles.panelOverlay, !isWheelOpen && styles.panelOverlayHidden, - // Laid out ahead of the toggle, at opacity 0 (useWheelsWarmUp). - !isWheelOpen && isWheelsWarm && styles.panelOverlayWarm, )}> {monthYearWheels} </div> diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 8f4fa5a4e41e9..4395c76478529 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -40,10 +40,13 @@ import { type CastFrame, type TrackedFrame, } from '@astryxdesign/interaction-harness'; -import {SHEET_SETTLE_MS} from '../useWheelsWarmUp'; +import {datePicker} from '../../motion/uikit/datePicker'; const storybook = withStorybook(test); +/** The title chevron's quarter turn, from the UIKit table. */ +const TURN = `rotate(-${datePicker.calendar.monthYearToggle.chevronTurnDegrees}deg)`; + /** CSS's named cubic-bézier easings, which a browser may report by name. */ const NAMED_EASINGS: Record<string, ReadonlyArray<number>> = { ease: [0.25, 0.1, 0.25, 1], @@ -218,18 +221,7 @@ async function openCalendar( .locator('.astryx-bottom-sheet') .first(); await settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); - // Under a finger the hidden wheels warm up once the sheet has settled - // (useWheelsWarmUp): their `visibility` turns visible at opacity 0, with its - // own transition. Wait for its end, so a test's first tap meets a panel at - // rest. `blockMainThread` and `armHold` take any running animation - // on the panel, and on a slow runner the warm-up can still be running. const wheels = page.locator('[data-panel="wheels"]').first(); - await expect - .poll(async () => wheels.evaluate(e => getComputedStyle(e).visibility), { - timeout: SHEET_SETTLE_MS + 2000, - }) - .toBe('visible'); - await settlesAt(wheels, 'visibility', 'visible'); const title = page.locator('[data-title="month-year"]'); const titleBox = await title.boundingBox(); if (titleBox == null) { @@ -293,12 +285,10 @@ test('the wheels fade in and the chevron turns on the theme’s tokens nearest U ms: tokens.ms, ease: tokens.entrance, }); - expectToggleRun( - await turn.started(), - 'transform', - ['rotate(-90deg)', 'rotate(0deg)'], - {ms: tokens.ms, ease: tokens.move}, - ); + expectToggleRun(await turn.started(), 'transform', [TURN, 'rotate(0deg)'], { + ms: tokens.ms, + ease: tokens.move, + }); }); test('the wheels fade out on the exit curve when they close', async ({ @@ -353,12 +343,10 @@ test('DateTimeInput: the calendar and the wheels cross-fade on the theme’s mot ms: tokens.ms, ease: tokens.entrance, }); - expectToggleRun( - await turn.started(), - 'transform', - ['rotate(-90deg)', 'rotate(0deg)'], - {ms: tokens.ms, ease: tokens.move}, - ); + expectToggleRun(await turn.started(), 'transform', [TURN, 'rotate(0deg)'], { + ms: tokens.ms, + ease: tokens.move, + }); }); test('a second tap mid-fade turns the fade around from where it is', async ({ @@ -895,41 +883,6 @@ async function wheelsState(page: Page, selector: string) { }); } -for (const [name, story, selector] of [ - ['DateInput', STORY, '[data-panel="wheels"]'], - ['DateTimeInput', DTI_STORY, '[data-date-surface="wheels"]'], -] as const) { - test(`${name}: once the sheet settles, the hidden wheels are laid out at opacity 0, still inert`, async ({ - page, - }) => { - // Their cylinders' layers then exist before the title is tapped. So the - // toggle's commit only changes opacity, and Mobile Safari builds nothing in - // the fade's first frames. - if (story === STORY) { - await openCalendar(page); - } else { - await openStory(page, storybook(), story); - const driver = await touchDriver(page); - const opener = page.getByRole('button', {name: /^Open /}).first(); - const box = await opener.boundingBox(); - if (box == null) { - throw new Error('no opener'); - } - await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); - } - await expect(page.locator(selector)).toBeAttached(); - await expect - .poll(async () => (await wheelsState(page, selector)).visibility, { - timeout: SHEET_SETTLE_MS + 2000, - }) - .toBe('visible'); - const state = await wheelsState(page, selector); - expect(state.opacity).toBe('0'); - expect(state.inert).toBe(true); - expect(state.ariaHidden).toBe('true'); - }); -} - type CommitLog = { frame: number; /** The document timeline's time, ms. */ @@ -973,11 +926,11 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ const wheels = page .locator(`${panelSelector.slice(0, -1)}="wheels"]`) .first(); - await expect - .poll(async () => wheels.evaluate(e => getComputedStyle(e).visibility), { - timeout: SHEET_SETTLE_MS + 2000, - }) - .toBe('visible'); + await settlesAt( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + 'transform', + 'matrix(1, 0, 0, 1, 0, 0)', + ); const tokens = await toggleTokens(page); await page.evaluate( ({selector, key}) => { diff --git a/packages/core/src/DateInput/datePickerTouch.ts b/packages/core/src/DateInput/datePickerTouch.ts index 777139ccd7d0e..e12c800f863ae 100644 --- a/packages/core/src/DateInput/datePickerTouch.ts +++ b/packages/core/src/DateInput/datePickerTouch.ts @@ -4,7 +4,7 @@ * @file datePickerTouch.ts * @input The date and time pickers' touch-only pieces * @output Re-exports what the date and time sheets use only under a finger: - * TimeWheels, MonthPager, useWheelsWarmUp and useChevronDip + * TimeWheels and MonthPager * @position The pickers' touch layer. The sheets import touch behaviour from * here only, so a desktop build can replace this one module. * @@ -15,5 +15,3 @@ export {TimeWheels} from '../TimeInput/TimeWheels'; export type {TimeWheelsProps} from '../TimeInput/TimeWheels'; export {MonthPager} from './MonthPager'; -export {useChevronDip} from './useChevronDip'; -export {useWheelsWarmUp} from './useWheelsWarmUp'; diff --git a/packages/core/src/DateInput/monthYearToggle.stylex.ts b/packages/core/src/DateInput/monthYearToggle.stylex.ts deleted file mode 100644 index a4a41de7b834e..0000000000000 --- a/packages/core/src/DateInput/monthYearToggle.stylex.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file monthYearToggle.stylex.ts - * @input StyleX constants - * @output Exports monthYearToggleMotion, the chevron turn of UIDatePicker's - * month/year toggle for the calendar under a finger - * @position Internal constants consumed by TouchDateField.tsx and - * TouchDateTimeField.tsx; not a package export (the released DateInput - * tokens live in tokens.stylex.ts) - * - * SYNC: When modified, update: - * - /packages/core/src/motion/uikit/datePicker.ts (the measured values) - * - /packages/core/src/DateInput/DateInputTouch.test.tsx (holds them equal) - */ - -import * as stylex from '@stylexjs/stylex'; -/** - * UIDatePicker's month/year toggle under a finger, from the UIKit constants - * table (motion/uikit/datePicker.ts `calendar.monthYearToggle`). Its fades - * and turn play the theme's tokens; only the turn's angle comes from the - * table. StyleX inlines only literal constants, so a test holds it to the - * table. - */ -export const monthYearToggleMotion = stylex.defineConsts({ - /** The title chevron's quarter turn: chevronTurnDegrees. */ - chevronTurn: '90deg', -}); diff --git a/packages/core/src/DateInput/useChevronDip.test.tsx b/packages/core/src/DateInput/useChevronDip.test.tsx deleted file mode 100644 index fcc07f8b682d3..0000000000000 --- a/packages/core/src/DateInput/useChevronDip.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -import {fireEvent, render} from '@testing-library/react'; -import {useState} from 'react'; -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import * as reducedMotion from '../motion/reducedMotion'; -import {useChevronDip} from './useChevronDip'; - -vi.mock('../motion/reducedMotion', async importOriginal => ({ - ...(await importOriginal<typeof reducedMotion>()), - playReducedMotionDip: vi.fn(), -})); - -const {playReducedMotionDip} = reducedMotion; - -const CLOSED = 'matrix(0, -1, 1, 0, 0, 0)'; - -function Title() { - const [isOpen, setIsOpen] = useState(false); - const chevron = useChevronDip(); - return ( - <button - ref={chevron.ref} - type="button" - data-open={isOpen} - onClick={() => { - chevron.dip(); - setIsOpen(!isOpen); - }}> - <span className="astryx-icon" /> - </button> - ); -} - -describe('useChevronDip under Reduced Motion on a touch screen', () => { - const dips: {cancel: ReturnType<typeof vi.fn>}[] = []; - let glyphOpacity = '1'; - beforeEach(() => { - dips.length = 0; - glyphOpacity = '1'; - vi.stubGlobal('matchMedia', (query: string) => ({ - matches: - query.includes('prefers-reduced-motion: reduce') || - /pointer:\s*coarse/.test(query), - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - })); - Element.prototype.animate = vi.fn() as typeof Element.prototype.animate; - const computed = window.getComputedStyle.bind(window); - vi.spyOn(window, 'getComputedStyle').mockImplementation(element => - element.classList.contains('astryx-icon') - ? ({ - opacity: glyphOpacity, - transform: (element as HTMLElement).style.transform || CLOSED, - } as CSSStyleDeclaration) - : computed(element), - ); - vi.mocked(playReducedMotionDip).mockClear(); - vi.mocked(playReducedMotionDip).mockImplementation(() => { - const dip = {cancel: vi.fn()}; - dips.push(dip); - return dip; - }); - }); - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('holds the shown turn until the dip lands, then lets the new state turn it', () => { - const view = render(<Title />); - const glyph = view.container.querySelector<HTMLElement>('.astryx-icon')!; - fireEvent.click(view.getByRole('button')); - expect(dips).toHaveLength(1); - const [element, , land, from] = - vi.mocked(playReducedMotionDip).mock.calls[0]; - expect(element).toBe(glyph); - expect(from).toBe(1); - expect(glyph.style.transform).toBe(CLOSED); - land(); - expect(glyph.style.transform).toBe(''); - }); - - it('restarts a second dip within the first from the glyph’s presented opacity, and cancels the first', () => { - const view = render(<Title />); - fireEvent.click(view.getByRole('button')); - // Part way into the first dip. - glyphOpacity = '0.4'; - fireEvent.click(view.getByRole('button')); - expect(dips).toHaveLength(2); - expect(dips[0].cancel).toHaveBeenCalledTimes(1); - expect(vi.mocked(playReducedMotionDip).mock.calls[1][3]).toBe(0.4); - }); - - it('cancels its dip on unmount', () => { - const view = render(<Title />); - fireEvent.click(view.getByRole('button')); - view.unmount(); - expect(dips[0].cancel).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/core/src/DateInput/useChevronDip.ts b/packages/core/src/DateInput/useChevronDip.ts deleted file mode 100644 index 9c4016016a5af..0000000000000 --- a/packages/core/src/DateInput/useChevronDip.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -'use client'; - -/** - * @file useChevronDip.ts - * @input The title button, and the Reduced Motion policy - * (motion/reducedMotion.ts) - * @output Exports `useChevronDip`: a callback ref for the title button, and - * `dip`, which the toggle's handler calls before it changes the state - * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx - * - * Under reduced motion on a touch screen the chevron does not turn. It dips - * instead (playReducedMotionDip), and the turn lands at the dip's middle. - * - * SYNC: When modified, update MonthYearToggle.interaction.browser.spec.ts. - */ - -import {useCallback, useEffect, useRef} from 'react'; -import { - playReducedMotionDip, - reducedMotionFade, - type ReducedMotionDip, -} from '../motion/reducedMotion'; - -/** The title chevron's Reduced Motion dip. */ -export interface ChevronDip { - /** A callback ref for the title button. */ - ref: (element: Element | null) => void; - /** - * The toggle's handler calls this before it changes the state. Under - * reduced motion on a touch screen, the chevron dips, and its turn lands at - * the dip's middle. Otherwise it does nothing. - */ - dip: () => void; -} - -/** - * The title chevron's Reduced Motion dip. The ref sits on the button because - * a registry Icon does not forward one, and the dip finds the icon inside it. - * - * @example - * ``` - * const chevron = useChevronDip(); - * const toggle = () => { - * chevron.dip(); - * setIsWheelOpen(!isWheelOpen); - * }; - * ``` - */ -export function useChevronDip(): ChevronDip { - const elementRef = useRef<Element | null>(null); - const dipRef = useRef<ReducedMotionDip | null>(null); - const dip = useCallback(() => { - const icon = - elementRef.current?.querySelector<HTMLElement>('.astryx-icon') ?? null; - const fade = reducedMotionFade(icon); - if ( - icon == null || - fade == null || - fade.duration === 0 || - typeof icon.animate !== 'function' - ) { - return; - } - const style = getComputedStyle(icon); - // A second change within a dip restarts it from the glyph's presented - // opacity. - const from = dipRef.current == null ? 1 : Number(style.opacity); - // The chevron keeps the turn it shows until the dip's middle: an inline - // transform holds it over the styles of the new state. - const shown = style.transform; - dipRef.current?.cancel(); - icon.style.transform = shown; - dipRef.current = playReducedMotionDip( - icon, - fade, - () => { - icon.style.transform = ''; - }, - from, - ); - }, []); - // Synchronizes with the dip's animation: it does not outlive the title. - useEffect(() => () => dipRef.current?.cancel(), []); - const ref = useCallback((element: Element | null) => { - elementRef.current = element; - }, []); - return {ref, dip}; -} diff --git a/packages/core/src/DateInput/useFadeInert.test.tsx b/packages/core/src/DateInput/useMonthYearToggle.test.tsx similarity index 68% rename from packages/core/src/DateInput/useFadeInert.test.tsx rename to packages/core/src/DateInput/useMonthYearToggle.test.tsx index 4c714250dc42d..09a97b80658ba 100644 --- a/packages/core/src/DateInput/useFadeInert.test.tsx +++ b/packages/core/src/DateInput/useMonthYearToggle.test.tsx @@ -2,19 +2,27 @@ import {act, fireEvent, render} from '@testing-library/react'; import {useLayoutEffect, useRef, useState} from 'react'; -import {describe, expect, it} from 'vitest'; -import {useFadeInert} from './useFadeInert'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import * as reducedMotion from '../motion/reducedMotion'; +import {useMonthYearToggle} from './useMonthYearToggle'; + +vi.mock('../motion/reducedMotion', async importOriginal => ({ + ...(await importOriginal<typeof reducedMotion>()), + playReducedMotionDip: vi.fn(), +})); + +const {playReducedMotionDip} = reducedMotion; function Panels({ isWheelOpen, - isDeferred = true, + isTouch = true, onCommit, onToggle, }: { isWheelOpen: boolean; - isDeferred?: boolean; + isTouch?: boolean; onCommit?: () => void; - /** Renders a toggle that hands focus over, as the sheets' handlers do. */ + /** Renders a toggle that hands focus over, as the sheets' title does. */ onToggle?: (next: boolean) => void; }) { // Every commit, to count the renders a toggle costs. @@ -23,21 +31,17 @@ function Panels({ }); const calendarRef = useRef<HTMLDivElement | null>(null); const wheelsRef = useRef<HTMLDivElement | null>(null); - const {isCalendarInert, isWheelsInert, followFocus} = useFadeInert( + const {isCalendarInert, isWheelsInert, toggle} = useMonthYearToggle({ isWheelOpen, + setIsWheelOpen: next => onToggle?.(next), + isTouch, calendarRef, wheelsRef, - isDeferred, - ); + }); return ( <> {onToggle != null && ( - <button - type="button" - onClick={() => { - followFocus(!isWheelOpen); - onToggle(!isWheelOpen); - }}> + <button type="button" onClick={() => toggle(!isWheelOpen)}> Toggle </button> )} @@ -103,7 +107,7 @@ async function fadeEnd(element: HTMLElement, type = 'transitionend') { }); } -describe('useFadeInert', () => { +describe('useMonthYearToggle: inert and focus', () => { it('makes the outgoing panel inert when its fade ends, never with the toggle', async () => { const view = render(<Panels isWheelOpen={false} />); const calendar = view.getByTestId('calendar'); @@ -218,9 +222,9 @@ describe('useFadeInert', () => { }); it('swaps inert with the toggle with a mouse, fade or not', () => { - const view = render(<Panels isWheelOpen={false} isDeferred={false} />); + const view = render(<Panels isWheelOpen={false} isTouch={false} />); fading(view.getByTestId('calendar'), view.getByTestId('wheels')); - view.rerender(<Panels isWheelOpen isDeferred={false} />); + view.rerender(<Panels isWheelOpen isTouch={false} />); expect(view.getByTestId('calendar').hasAttribute('inert')).toBe(true); expect(view.getByTestId('wheels').hasAttribute('inert')).toBe(false); }); @@ -231,15 +235,13 @@ describe('useFadeInert', () => { commits += 1; }; const view = render( - <Panels isWheelOpen={false} isDeferred={false} onCommit={onCommit} />, + <Panels isWheelOpen={false} isTouch={false} onCommit={onCommit} />, ); commits = 0; - view.rerender( - <Panels isWheelOpen isDeferred={false} onCommit={onCommit} />, - ); + view.rerender(<Panels isWheelOpen isTouch={false} onCommit={onCommit} />); expect(commits).toBe(1); view.rerender( - <Panels isWheelOpen={false} isDeferred={false} onCommit={onCommit} />, + <Panels isWheelOpen={false} isTouch={false} onCommit={onCommit} />, ); expect(commits).toBe(2); }); @@ -279,3 +281,100 @@ describe('useFadeInert', () => { expect(document.activeElement).toBe(toggle); }); }); + +const CLOSED = 'matrix(0, -1, 1, 0, 0, 0)'; + +function Title() { + const [isOpen, setIsOpen] = useState(false); + const calendarRef = useRef<HTMLDivElement | null>(null); + const wheelsRef = useRef<HTMLDivElement | null>(null); + const {titleRef, toggle} = useMonthYearToggle({ + isWheelOpen: isOpen, + setIsWheelOpen: setIsOpen, + isTouch: true, + calendarRef, + wheelsRef, + }); + return ( + <button + ref={titleRef} + type="button" + data-open={isOpen} + onClick={() => toggle(!isOpen)}> + <span className="astryx-icon" /> + </button> + ); +} + +describe('useMonthYearToggle: the chevron under Reduced Motion on a touch screen', () => { + const dips: {cancel: ReturnType<typeof vi.fn>}[] = []; + let glyphOpacity = '1'; + beforeEach(() => { + dips.length = 0; + glyphOpacity = '1'; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: + query.includes('prefers-reduced-motion: reduce') || + /pointer:\s*coarse/.test(query), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); + Element.prototype.animate = vi.fn() as typeof Element.prototype.animate; + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => + element.classList.contains('astryx-icon') + ? ({ + opacity: glyphOpacity, + transform: (element as HTMLElement).style.transform || CLOSED, + } as CSSStyleDeclaration) + : computed(element), + ); + vi.mocked(playReducedMotionDip).mockClear(); + vi.mocked(playReducedMotionDip).mockImplementation(() => { + const dip = {cancel: vi.fn()}; + dips.push(dip); + return dip; + }); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('holds the shown turn until the dip lands, then lets the new state turn it', () => { + const view = render(<Title />); + const glyph = view.container.querySelector<HTMLElement>('.astryx-icon')!; + fireEvent.click(view.getByRole('button')); + expect(dips).toHaveLength(1); + const [element, , land, from] = + vi.mocked(playReducedMotionDip).mock.calls[0]; + expect(element).toBe(glyph); + expect(from).toBe(1); + expect(glyph.style.transform).toBe(CLOSED); + land(); + expect(glyph.style.transform).toBe(''); + }); + + it('restarts a second dip within the first from the glyph’s presented opacity, and cancels the first', () => { + const view = render(<Title />); + fireEvent.click(view.getByRole('button')); + // Part way into the first dip. + glyphOpacity = '0.4'; + fireEvent.click(view.getByRole('button')); + expect(dips).toHaveLength(2); + expect(dips[0].cancel).toHaveBeenCalledTimes(1); + expect(vi.mocked(playReducedMotionDip).mock.calls[1][3]).toBe(0.4); + }); + + it('cancels its dip on unmount', () => { + const view = render(<Title />); + fireEvent.click(view.getByRole('button')); + view.unmount(); + expect(dips[0].cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/DateInput/useFadeInert.ts b/packages/core/src/DateInput/useMonthYearToggle.ts similarity index 68% rename from packages/core/src/DateInput/useFadeInert.ts rename to packages/core/src/DateInput/useMonthYearToggle.ts index 8a70d274145cd..f1fe45dfec95f 100644 --- a/packages/core/src/DateInput/useFadeInert.ts +++ b/packages/core/src/DateInput/useMonthYearToggle.ts @@ -2,25 +2,42 @@ 'use client'; /** - * @file useFadeInert.ts - * @input Whether the month/year wheels are open, and the two panels the - * month/year toggle cross-fades (the calendar and the wheels) - * @output Exports `useFadeInert`: which panel is `inert`, whether the - * toggle's cross-fade runs, and the toggle's focus hand-off + * @file useMonthYearToggle.ts + * @input Whether the month/year wheels show, whether a finger drives the + * sheet, and the two panels the toggle cross-fades + * @output Exports `useMonthYearToggle`: which panel is `inert`, whether the + * cross-fade runs, the title's ref, and `toggle`, which the title and Done + * call * @position Internal hook of TouchDateField.tsx and TouchDateTimeField.tsx * - * Under a finger, the panels swap `inert` when the outgoing panel's exit - * ends. With a mouse they swap it with the toggle. The toggle's handler calls - * `followFocus`, so focus leaves the outgoing panel in the toggle's commit. + * SYNC: When modified, update useMonthYearToggle.test.tsx and + * MonthYearToggle.interaction.browser.spec.ts. */ import {useCallback, useEffect, useRef, useState, type RefObject} from 'react'; import {FOCUSABLE_SELECTOR} from '../hooks/focusableSelector'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import { + playReducedMotionDip, + reducedMotionFade, + type ReducedMotionDip, +} from '../motion/reducedMotion'; /** A month grid's tab stop: one day in each pane that has one. */ const TAB_STOP = '[tabindex="0"]'; +/** + * Frames the hand-off tries for before it gives up: about half a second, + * longer than the toggle's fade. + */ +const FOCUS_FRAMES = 30; + +/** + * The properties that carry a panel's exit. The layer on top fades its + * `opacity`. The layer beneath holds `visibility` as long, then takes `hidden`. + */ +const EXIT_PROPERTIES = new Set(['opacity', 'visibility']); + /** * The tab stop of the month in view. The mouse calendar gives every mounted * month a tab stop, and the months beside the shown one sit outside the @@ -40,18 +57,6 @@ function shownTabStop(panel: HTMLElement): HTMLElement | null { return stops[0] ?? null; } -/** - * Frames the hand-off tries for before it gives up: about half a second, - * longer than the toggle's fade. - */ -const FOCUS_FRAMES = 30; - -/** - * The properties that carry a panel's exit. The layer on top fades its - * `opacity`. The layer beneath holds `visibility` as long, then takes `hidden`. - */ -const EXIT_PROPERTIES = new Set(['opacity', 'visibility']); - /** Whether a transition of `property` runs on the element itself. */ function isRunning(element: Element, property: string): boolean { if (typeof element.getAnimations !== 'function') { @@ -81,27 +86,57 @@ function isExiting(element: Element): boolean { ); } -/** - * `inert` is inherited, so flipping it restyles a panel's whole subtree; - * here both flips wait for the cross-fade to end, when nothing on screen - * moves. Meanwhile the caller's `aria-hidden` keeps assistive technology out - * of the outgoing panel. The caller's shield keeps touches off both, as a - * UIKit view animation does. - */ -export function useFadeInert( - isWheelOpen: boolean, - calendarRef: RefObject<HTMLElement | null>, - wheelsRef: RefObject<HTMLElement | null>, +interface MonthYearToggleOptions { + /** Whether the month/year wheels show. */ + isWheelOpen: boolean; + /** Sets it. `toggle` calls it after the focus hand-off and the dip. */ + setIsWheelOpen: (isOpen: boolean) => void; /** - * Under a finger: wait for the fade. With a mouse the panels swap `inert` - * with the toggle, so a click right after it lands. + * Whether a finger drives the sheet. Under a finger the panels swap `inert` + * when the fade ends. With a mouse they swap it with the toggle, so a click + * right after it lands. */ - isDeferred: boolean, -): { + isTouch: boolean; + calendarRef: RefObject<HTMLElement | null>; + wheelsRef: RefObject<HTMLElement | null>; +} + +/** + * The month/year toggle of the touch date fields' calendar sheet. + * + * `inert` is inherited, so flipping it restyles a panel's whole subtree. + * Under a finger both flips wait for the cross-fade to end, when nothing on + * screen moves. Meanwhile the caller's `aria-hidden` keeps assistive + * technology out of the outgoing panel, and the caller's shield keeps touches + * off both, as a UIKit view animation does. `toggle` moves focus inside the + * outgoing panel into the incoming one in the toggle's commit. + * + * Under Reduced Motion on a touch screen the title's chevron does not turn. + * It dips instead (playReducedMotionDip), and the turn lands at the dip's + * middle. + * + * @example + * ``` + * const {isCalendarInert, isWheelsInert, isFading, titleRef, toggle} = + * useMonthYearToggle({isWheelOpen, setIsWheelOpen, isTouch, calendarRef, wheelsRef}); + * <button ref={titleRef} onClick={() => toggle(!isWheelOpen)} /> + * ``` + */ +export function useMonthYearToggle({ + isWheelOpen, + setIsWheelOpen, + isTouch, + calendarRef, + wheelsRef, +}: MonthYearToggleOptions): { isCalendarInert: boolean; isWheelsInert: boolean; + /** True while the cross-fade runs under a finger: the caller's shield shows. */ isFading: boolean; - followFocus: (next: boolean) => void; + /** A callback ref for the title button: the chevron's dip finds its icon. */ + titleRef: (element: Element | null) => void; + /** Opens (`true`) or closes the wheels. The title and Done call it. */ + toggle: (next: boolean) => void; } { // `settled`: the toggle state whose exit has ended. `focusedFor`: the // toggle state whose incoming panel took focus, and so is live early. @@ -112,7 +147,7 @@ export function useFadeInert( // With a mouse the panels swap `inert` with the toggle. So `settled` // follows `isWheelOpen` during render, React's pattern for state that // tracks a prop, and the toggle costs one commit. - if (!isDeferred && settled !== isWheelOpen) { + if (!isTouch && settled !== isWheelOpen) { setFade({settled: isWheelOpen, focusedFor: null}); } const isFading = settled !== isWheelOpen; @@ -148,8 +183,7 @@ export function useFadeInert( // The cross-fade ends when either panel's own transition ends. The layer // beneath may have none: it stays visible, covered. Only a transition // that runs after the toggle counts. One already running ends on its own - // clock. The wheels' warm-up turns their `visibility` on, and a toggle - // soon after would otherwise settle at its end. + // clock. const started = new Set<string>(); const keyOf = (event: Event) => { const index = panels.indexOf(event.target as HTMLElement); @@ -205,11 +239,8 @@ export function useFadeInert( const pendingFocusRef = useRef<{incoming: HTMLElement; from: Element} | null>( null, ); - /** - * The toggle's event handler calls this before it changes the state, with - * the state it sets. Focus inside the outgoing panel then moves to the - * incoming one in the toggle's commit, never staying in a fading panel. - */ + // Focus inside the outgoing panel moves to the incoming one in the toggle's + // commit, never staying in a fading panel. const followFocus = useCallback( (next: boolean) => { const outgoing = (next ? calendarRef : wheelsRef).current; @@ -265,12 +296,61 @@ export function useFadeInert( // Synchronizes with the frame clock: a pending retry ends with the hook. useEffect(() => () => cancelAnimationFrame(focusFrameRef.current), []); - if (!isDeferred) { + const titleElementRef = useRef<Element | null>(null); + const dipRef = useRef<ReducedMotionDip | null>(null); + const dipChevron = useCallback(() => { + const icon = + titleElementRef.current?.querySelector<HTMLElement>('.astryx-icon') ?? + null; + if (icon == null || typeof icon.animate !== 'function') { + return; + } + const fade = reducedMotionFade(icon); + if (fade == null || fade.duration === 0) { + return; + } + const style = getComputedStyle(icon); + // A second change within a dip restarts it from the glyph's presented + // opacity. + const from = dipRef.current == null ? 1 : Number(style.opacity); + // The chevron keeps the turn it shows until the dip's middle: an inline + // transform holds it over the styles of the new state. + const shown = style.transform; + dipRef.current?.cancel(); + icon.style.transform = shown; + dipRef.current = playReducedMotionDip( + icon, + fade, + () => { + icon.style.transform = ''; + }, + from, + ); + }, []); + // Synchronizes with the dip's animation: it does not outlive the title. + useEffect(() => () => dipRef.current?.cancel(), []); + // The ref sits on the title button because a registry Icon does not forward + // one, and the dip finds the icon inside it. + const titleRef = useCallback((element: Element | null) => { + titleElementRef.current = element; + }, []); + + const toggle = useCallback( + (next: boolean) => { + followFocus(next); + dipChevron(); + setIsWheelOpen(next); + }, + [followFocus, dipChevron, setIsWheelOpen], + ); + + if (!isTouch) { return { isCalendarInert: isWheelOpen, isWheelsInert: !isWheelOpen, isFading: false, - followFocus, + titleRef, + toggle, }; } const isIncomingLive = isFading && focusedFor === isWheelOpen; @@ -282,6 +362,7 @@ export function useFadeInert( ? !settled && !(isIncomingLive && isWheelOpen) : !isWheelOpen, isFading, - followFocus, + titleRef, + toggle, }; } diff --git a/packages/core/src/DateInput/useWheelsWarmUp.test.tsx b/packages/core/src/DateInput/useWheelsWarmUp.test.tsx deleted file mode 100644 index c3b90d9e433df..0000000000000 --- a/packages/core/src/DateInput/useWheelsWarmUp.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file useWheelsWarmUp.test.tsx - * @input useWheelsWarmUp, the motion math and the UIKit constants table - * @output Unit tests: the hidden wheels warm once the open sheet has settled - * (its present spring's settling duration) and the main thread is idle, and - * cool when it closes - * @position Colocated test of useWheelsWarmUp.ts - */ - -import {act, renderHook} from '@testing-library/react'; -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {settlingDuration, toSpring} from '../motion/spring'; -import {springs} from '../motion/uikit/springs'; -import {SHEET_SETTLE_MS, useWheelsWarmUp} from './useWheelsWarmUp'; - -beforeEach(() => { - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe('useWheelsWarmUp', () => { - it('waits for the sheet\u2019s present spring to settle', () => { - expect(SHEET_SETTLE_MS).toBe( - Math.round(settlingDuration(toSpring(springs.sheetPresent)) * 1000), - ); - expect(SHEET_SETTLE_MS).toBeGreaterThan(0); - }); - - it('warms once the open sheet has settled and the main thread is idle', () => { - const idle: (() => void)[] = []; - vi.stubGlobal('requestIdleCallback', (callback: () => void) => { - idle.push(callback); - return idle.length; - }); - vi.stubGlobal('cancelIdleCallback', () => {}); - const {result, rerender} = renderHook( - ({isOpen}) => useWheelsWarmUp(isOpen), - {initialProps: {isOpen: false}}, - ); - expect(result.current).toBe(false); - rerender({isOpen: true}); - act(() => { - vi.advanceTimersByTime(SHEET_SETTLE_MS - 1); - }); - expect(idle).toHaveLength(0); - act(() => { - vi.advanceTimersByTime(1); - }); - expect(idle).toHaveLength(1); - expect(result.current).toBe(false); - act(() => { - idle[0]?.(); - }); - expect(result.current).toBe(true); - rerender({isOpen: false}); - expect(result.current).toBe(false); - }); - - it('never warms a sheet that closes before it settles', () => { - const {result, rerender} = renderHook( - ({isOpen}) => useWheelsWarmUp(isOpen), - {initialProps: {isOpen: true}}, - ); - rerender({isOpen: false}); - act(() => { - vi.advanceTimersByTime(SHEET_SETTLE_MS + 1000); - }); - expect(result.current).toBe(false); - }); -}); diff --git a/packages/core/src/DateInput/useWheelsWarmUp.ts b/packages/core/src/DateInput/useWheelsWarmUp.ts deleted file mode 100644 index f4fba33c64505..0000000000000 --- a/packages/core/src/DateInput/useWheelsWarmUp.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -'use client'; - -/** - * @file useWheelsWarmUp.ts - * @input Whether the sheet holding the month/year wheels is open - * @output Exports `useWheelsWarmUp`: true once the open sheet has settled and - * the main thread is idle, so the hidden wheels lay out before the toggle - * @position Internal hook of the pickers' touch layer (datePickerTouch.ts) - * - * WebKit builds a layer for every wheel row in the commit that shows them. - * Built inside the toggle's commit, that cost the fade's first frames. - * - * SYNC: When modified, update useWheelsWarmUp.test.tsx and - * MonthYearToggle.interaction.browser.spec.ts. - */ - -import {useEffect, useState} from 'react'; - -/** - * How long the sheet's present motion (`springs.sheetPresent`) takes to - * settle, in milliseconds. A constant, not a table read, so the pickers ship - * none of the springs table's other rows. The test pins it to the spring. - */ -export const SHEET_SETTLE_MS = 506; - -/** The longest an idle callback may wait before the wheels warm anyway. */ -const IDLE_TIMEOUT_MS = 300; - -/** - * True once the sheet has been open for its present motion's settling - * duration and the main thread has gone idle; false while it is closed. The - * caller then shows the hidden wheels at opacity 0, still inert, so the - * toggle's commit only changes opacity. - * - * @example - * ``` - * const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); - * ``` - */ -export function useWheelsWarmUp(isSheetOpen: boolean): boolean { - const [isWarm, setIsWarm] = useState(false); - // Synchronizes with the browser's clock and idle time. The wheels warm after - // the sheet's present motion settles and the main thread goes idle. Closing - // the sheet cancels both and cools them. - useEffect(() => { - if (!isSheetOpen) { - return undefined; - } - let idle: number | null = null; - const warm = () => { - setIsWarm(true); - }; - const timer = window.setTimeout(() => { - idle = - typeof window.requestIdleCallback === 'function' - ? window.requestIdleCallback(warm, {timeout: IDLE_TIMEOUT_MS}) - : window.setTimeout(warm, 0); - }, SHEET_SETTLE_MS); - return () => { - // Cooled for the next opening, which waits for its own settle. - setIsWarm(false); - window.clearTimeout(timer); - if (idle != null) { - if (typeof window.cancelIdleCallback === 'function') { - window.cancelIdleCallback(idle); - } else { - window.clearTimeout(idle); - } - } - }; - }, [isSheetOpen]); - return isSheetOpen && isWarm; -} diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index ab8abda038602..3f7381aab9eec 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -895,7 +895,7 @@ describe('DateTimeInput touch surface', () => { return element; }; // A running exit on both surfaces, so their own `inert` waits for the - // fade's end (useFadeInert). + // fade's end (useMonthYearToggle). for (const which of ['calendar', 'wheels'] as const) { surface(which).getAnimations = () => [ diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index f6c75560d47de..dc461c2829bec 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -105,12 +105,7 @@ import { } from '../DateInput/MonthScroller'; import {MonthYearWheels} from '../DateInput/MonthYearWheels'; import {Wheel, type WheelOption} from '../DateInput/Wheel'; -import { - MonthPager, - TimeWheels, - useChevronDip, - useWheelsWarmUp, -} from '../DateInput/datePickerTouch'; +import {MonthPager, TimeWheels} from '../DateInput/datePickerTouch'; import { hour12From24, hour24From12, @@ -120,9 +115,8 @@ import { secondRows, wheelBounds, } from '../TimeInput/timeWheelRules'; -import {useFadeInert} from '../DateInput/useFadeInert'; +import {useMonthYearToggle} from '../DateInput/useMonthYearToggle'; import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; -import {monthYearToggleMotion} from '../DateInput/monthYearToggle.stylex'; import {useTouchPresentation} from '../utils/touchPresentation'; import { dateInputTouchGeometry, @@ -207,7 +201,6 @@ const TOGGLE_DURATION = durationVars['--duration-medium-min']; const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; const TOGGLE_EXIT = easeVars['--ease-exit']; const TOGGLE_TURN = easeVars['--ease-move']; -const CHEVRON_TURN = monthYearToggleMotion.chevronTurn; const styles = stylex.create({ iconButton: { @@ -420,7 +413,7 @@ const styles = stylex.create({ /** * While the month/year toggle cross-fades, a shield over both surfaces * takes touches, as a UIKit view animation ignores them. `inert` swaps only - * at the fade's end (useFadeInert). A pseudo-element, so showing it + * at the fade's end (useMonthYearToggle). A pseudo-element, so showing it * restyles nothing inside the surfaces. */ touchDateSurfaceStackFading: { @@ -473,26 +466,21 @@ const styles = stylex.create({ reducedMotionFadeConsts.easing, }, }, - // The hidden wheels under a finger once the sheet has settled: laid out, at - // opacity 0, so the toggle's commit only changes their opacity - // (useWheelsWarmUp). Still inert and hidden from assistive technology. - touchDateSurfaceWarm: { - visibility: 'visible', - }, // Under a finger, UIDatePicker's chevron: it points along the reading // direction for the calendar and turns down on the toggle's clock. With a // mouse it points down and stays. Under Reduced Motion it does not turn. - // The turn lands mid-dip on a touch screen (useChevronDip). + // The turn lands mid-dip on a touch screen (useMonthYearToggle). A test + // holds the quarter turn to the UIKit table. touchTitleChevron: { display: 'inline-flex', flexShrink: 0, transform: { default: 'none', '@media (pointer: coarse)': { - default: `rotate(-${CHEVRON_TURN})`, + default: 'rotate(-90deg)', // Mirrored under RTL, so it points along the reading direction; the // same function list in both states keeps the turn a turn. - ':is([dir="rtl"] *)': `scaleX(-1) rotate(-${CHEVRON_TURN})`, + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(-90deg)', }, }, transitionProperty: { @@ -713,27 +701,25 @@ export function TouchDateTimeField({ ); const [activePanel, setActivePanel] = useState<TouchDateTimePanel>('date'); const [isWheelOpen, setIsWheelOpen] = useState(false); - // The surfaces swap `inert` when the toggle's fade ends (useFadeInert), + // The surfaces swap `inert` when the toggle's fade ends (useMonthYearToggle), // since flipping it restyles a surface's subtree. `aria-hidden` follows the // toggle at once, and a shield takes touches while the fade runs. const calendarSurfaceRef = useRef<HTMLDivElement | null>(null); const wheelsSurfaceRef = useRef<HTMLDivElement | null>(null); const {isTouch} = useTouchPresentation(); - const {isCalendarInert, isWheelsInert, isFading, followFocus} = useFadeInert( - isWheelOpen, - calendarSurfaceRef, - wheelsSurfaceRef, - isTouch, - ); + const {isCalendarInert, isWheelsInert, isFading, titleRef, toggle} = + useMonthYearToggle({ + isWheelOpen, + setIsWheelOpen, + isTouch, + calendarRef: calendarSurfaceRef, + wheelsRef: wheelsSurfaceRef, + }); // The header's arrows and Reset leave with the toggle and arrive with the // fade's end. They sit outside the surfaces' shield, so they go inert at // once. const isCalendarActionsInert = isWheelOpen || isCalendarInert; const MonthGrid = isTouch ? MonthPager : MonthScroller; - // Once the sheet settles, the hidden wheels are laid out at opacity 0, so - // the toggle itself only changes opacity. - const isWheelsWarm = useWheelsWarmUp(isSheetOpen && isTouch); - const {ref: chevronRef, dip: dipChevron} = useChevronDip(); const [draftTime, setDraftTime] = useState<ISOTimeString | undefined>(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1193,13 +1179,9 @@ export function TouchDateTimeField({ )}> <div {...stylex.props(styles.touchHeader)}> <button - ref={chevronRef} + ref={titleRef} type="button" - onClick={() => { - followFocus(!isWheelOpen); - dipChevron(); - setIsWheelOpen(!isWheelOpen); - }} + onClick={() => toggle(!isWheelOpen)} aria-expanded={isWheelOpen} aria-label={t('@astryx.dateInput.chooseMonthYear', { monthYear: monthYearLabel, @@ -1340,8 +1322,6 @@ export function TouchDateTimeField({ {...stylex.props( styles.touchDateSurface, !isWheelOpen && styles.touchDateSurfaceHidden, - // Laid out ahead of the toggle, at opacity 0 (useWheelsWarmUp). - !isWheelOpen && isWheelsWarm && styles.touchDateSurfaceWarm, )}> <div aria-hidden="true" @@ -1364,11 +1344,7 @@ export function TouchDateTimeField({ size="md" width="100%" label={t('@astryx.dateInput.doneChoosingMonth')} - onClick={() => { - followFocus(false); - dipChevron(); - setIsWheelOpen(false); - }} + onClick={() => toggle(false)} /> </div> </div> From 6404e3db33b8c0536cb07484577c3f053680eb62 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 00:36:29 -0800 Subject: [PATCH 34/40] docs(DateInput): name the month/year toggle's hook in FR25 --- docs/specs/components/DateInput/DateInput.spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index b623f9734876f..81ca2d5e64ef7 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -191,7 +191,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR22 | In the touch sheet, a month arrow at the range end is hidden; if it held focus, focus moves first to the other month arrow, or to the month-and-year button when both are hidden (`spec:AST-085/FR4`). | `spec:AST-085/FR4`; current source and sheet arrow tests | | FR23 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | | FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the theme duration token nearest UIKit's measured 250 ms in the Storybook theme (`--duration-medium-min`, 225 ms there) and the move easing (`--ease-move`), as `spec:AST-094` FR45 asks. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | -| FR25 | Under a coarse primary pointer, while the bottom-sheet calendar's month/year toggle cross-fades, a tap MUST reach neither panel: the outgoing panel and the header's and footer's actions that leave with it stop taking taps with the toggle, and the incoming ones take taps once the outgoing panel's fade ends. Under a fine pointer they swap with the toggle. When the title or Done toggles while focus is inside the outgoing panel, focus MUST move into the incoming panel once that panel can take focus, onto the shown month's tab stop in the calendar; focus outside the panels stays where it is. | UIDatePicker on the iOS 26.2 simulator, whose toggle takes no touch mid-fade; the APG grid's single tab stop | +| FR25 | Under a coarse primary pointer, while the bottom-sheet calendar's month/year toggle cross-fades, a tap MUST reach neither panel: the outgoing panel and the header's and footer's actions that leave with it stop taking taps with the toggle, and the incoming ones take taps once the outgoing panel's fade ends. Under a fine pointer they swap with the toggle. When the title or Done toggles while focus is inside the outgoing panel, focus MUST move into the incoming panel once that panel can take focus, onto the shown month's tab stop in the calendar; focus outside the panels stays where it is. The internal `useMonthYearToggle` hook gives both touch fields this behavior and FR24's chevron dip. | UIDatePicker on the iOS 26.2 simulator, whose toggle takes no touch mid-fade; the APG grid's single tab stop | ### Allowed variation From a675cf847d08da701063d9e6b05ccfa22ea460a1 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 02:32:52 -0800 Subject: [PATCH 35/40] chore(DateInput): plain, short comments in the month pager files A Reduced Motion case's comment named two pull requests. It now names only the animator default it relies on. Five comment sentences ran past 25 words, or past 20 for an instruction. Each now reads as two or more sentences, with the same meaning. --- .../MonthPager.interaction.browser.spec.ts | 13 +++++++------ .../MonthScrollerSteps.interaction.browser.spec.ts | 6 +++--- packages/core/src/DateInput/useOwnScrollGesture.ts | 6 +++--- packages/core/src/motion/uikit/datePicker.ts | 4 ++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts index f8d373b637125..3daea5ef4a5e6 100644 --- a/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -4,9 +4,10 @@ * @file MonthPager.interaction.browser.spec.ts * @input The built "Bottom Sheet Calendar" DateInput story and the MonthPager * proof story, real touch and trackpad input, and the interaction harness - * @output Interaction proofs that the sheet's calendar pages like UIDatePicker - * under a finger, in Chromium and WebKit: releases, flicks, the title, the - * arrows, the sheet split, the rubber band, RTL and Reduced Motion + * @output Interaction proofs, in Chromium and WebKit, that the sheet's + * calendar pages like UIDatePicker under a finger. They cover releases, + * flicks, the title, the arrows, the sheet split, the rubber band, RTL and + * Reduced Motion * @position Interaction tests of MonthPager.tsx (`*.interaction.browser.spec.ts`, * run by `pnpm test:interaction`) */ @@ -283,8 +284,8 @@ test('a flick released before half a month turns the title as the settle crosses flick(center, {velocity: {x: -2500, y: 0}, durationMs: 40, hz: 120}), ), ); - // Hold the page's frames right after the lift: the title must not have - // changed at the lift itself (UIDatePicker's title follows the content). + // Hold the page's frames right after the lift. UIDatePicker's title follows + // the content, so the title must not change at the lift itself. await freezeTimers(page); await page.waitForTimeout(80); expect(await title.textContent()).toBe('October 2026'); @@ -562,7 +563,7 @@ test('Reduced Motion: a release and the arrow step change the month through an o }) => { // design:reduced-motion DR2: a transition a touch release or a tap starts // changes opacity only, in at most 200 ms. The settle and the step come - // from usePager's animator default (C23's #8146 on F1's #8076). + // from usePager's animator default. await page.emulateMedia({reducedMotion: 'reduce'}); const {driver, viewport, title, width, center} = await openCalendar(page); await driver.play(slowDrag(center, -0.7 * width)); diff --git a/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts index 9228331cc67f3..3fbd4027d8b3d 100644 --- a/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthScrollerSteps.interaction.browser.spec.ts @@ -5,9 +5,9 @@ * @input The built "Default" DateInput story, shown in a bottom sheet, the * arrows clicked from the page, real mouse and trackpad input and the * interaction harness - * @output Interaction proofs for the month scroller's steps: after the month - * and year wheels close, a trackpad scroll still pages to the next month and - * Reset from four months on still rests on today's month; an arrow step + * @output Interaction proofs for the month scroller's steps. After the month + * and year wheels close, a trackpad scroll still pages to the next month. + * Reset from four months on still rests on today's month. An arrow step * through a stalled frame moves exactly one month. Under a primary finger * the calendar is MonthPager (MonthPager.interaction.browser.spec.ts). * (A second arrow step late in the first one's scroll, which WebKit cuts diff --git a/packages/core/src/DateInput/useOwnScrollGesture.ts b/packages/core/src/DateInput/useOwnScrollGesture.ts index 591d8ddee2b18..61a1a1c7dd2f6 100644 --- a/packages/core/src/DateInput/useOwnScrollGesture.ts +++ b/packages/core/src/DateInput/useOwnScrollGesture.ts @@ -148,9 +148,9 @@ export type OwnScrollGestureOptions = { * reinterpret them as its own drag. * * @param ref - the scroll container - * @param axis - `'all'` to take every touch, `'inline'` to take only the - * horizontal ones and leave vertical drags to the sheet, `'pager'` to split - * as UIKit does in a sheet + * @param axis - which touches to take. `'all'` takes every touch. `'inline'` + * takes only the horizontal ones and leaves vertical drags to the sheet. + * `'pager'` splits as UIKit does in a sheet * @param options - see {@link OwnScrollGestureOptions} */ export function useOwnScrollGesture( diff --git a/packages/core/src/motion/uikit/datePicker.ts b/packages/core/src/motion/uikit/datePicker.ts index 15575faa6df82..ae812b718df31 100644 --- a/packages/core/src/motion/uikit/datePicker.ts +++ b/packages/core/src/motion/uikit/datePicker.ts @@ -5,8 +5,8 @@ * @input Nothing; data only * @output Exports `datePicker`: the rules UIDatePicker adds to its calendar * and wheels. The calendar's month grid pages like a paging scroll view. - * The month/year wheels change the date, the AM/PM column follows the hour, - * a column turns back onto a bound, and a day clamps to its month. + * The month/year wheels change the date, and the AM/PM column follows the + * hour. A column turns back onto a bound, and a day clamps to its month. * @position The date picker domain of the UIKit constants table * (motion/uikit), for the date and time touch sheets. Every leaf names its * source, which uikit.test.ts checks. From f26df1b487eefde2475fd3713f8a0ea4076e493c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 02:33:39 -0800 Subject: [PATCH 36/40] docs(DateInput): name the month grid parts that MonthScroller exports MonthScroller.tsx now exports MonthPane, monthGridKeyTarget and useMonthGridFocus, with their types, and MonthPager imports them. DateInput's record names them in its Ownership boundary, as the one month grid both calendars render. --- docs/specs/components/DateInput/DateInput.spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 67a15d57e56a1..f11d3d6849c7a 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -128,6 +128,10 @@ Consumer migration instructions belong in consumer docs and release notes. - The loop's steps in `DateInput/wheelOptions.ts`: `restingLoopStep`, a rest's turn to the nearest enabled row, and `loopedStep`, a key's step across the seam (FR13, FR17, DEC-3). +- The month grid that `DateInput/MonthScroller.tsx` exports to `MonthPager`: + `MonthPane`, one month's grid, and `monthGridKeyTarget` and + `useMonthGridFocus`, its keys and its focus, with their types. Both calendars + render this one grid (PR2, AR8). - The disabled-reason Tooltip composition: DateInput renders the reason Tooltip and links it from its focusable controls (`family:input-fields` representative matrix, "Member with `disabledMessage`"). From af33cf0f00a0384762cd081ae82aa730d160d14f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 02:33:53 -0800 Subject: [PATCH 37/40] fix(DateTimeInput): keep the calendar drawn under the month/year wheels Under a finger, DateTimeInput's month/year toggle faded its calendar out while the wheels faded in. DateInput fades only the wheels: they sit on an opaque layer in the sheet's color, and the calendar stays drawn beneath them. DateTimeInput now does the same. Its wheels surface takes the opaque layer and its own stacking context. Under a finger its calendar keeps its style, and aria-hidden and inert keep it out of reach. With a mouse it still hides when the wheels show. A new interaction test runs on both fields. Before this change it failed for DateTimeInput, whose calendar ran an opacity fade from 1 to 0. --- .../calendar-toggle-uidatepicker-timing.md | 2 +- ...onthYearToggle.interaction.browser.spec.ts | 104 +++++++++++++++++- .../src/DateTimeInput/TouchDateTimeField.tsx | 22 +++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md index ad25855bcf34a..53be70dbac06f 100644 --- a/.changeset/calendar-toggle-uidatepicker-timing.md +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -4,7 +4,7 @@ [feat] Under a finger, the calendar in the DateInput and DateTimeInput bottom sheets now toggles to its month and year wheels like the iOS date picker: -- The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. +- The month/year toggle cross-fades over `--duration-medium-min` (225 ms in the neutral theme, the token nearest iOS's 0.25 s there), where DateInput used a quick linear fade and DateTimeInput an instant swap. The wheels arrive on the theme's entrance curve and leave on its exit curve. In both sheets the wheels fade over the calendar, which stays drawn beneath them. As in UIKit, the header's month arrows and Reset and the footer's buttons take no tap during the fade, and a second tap on the title turns it around. - The title's chevron points along the reading direction and turns a quarter turn to point down while the wheels show, on the theme's move curve. In DateInput it was a down chevron that flipped up; in DateTimeInput it was a down chevron that stayed still. - A finger's tapped day takes its selection at once, with no colour transition. A mouse or pen pick keeps the fade, in the touch sheet too. diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 4395c76478529..77e67ba80ca06 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -314,7 +314,7 @@ test('the wheels fade out on the exit curve when they close', async ({ }); }); -test('DateTimeInput: the calendar and the wheels cross-fade on the theme’s motion tokens, the title chevron turning with them', async ({ +test('DateTimeInput: the wheels fade in over the calendar on the theme’s motion tokens, the title chevron turning with them', async ({ page, }) => { await openStory(page, storybook(), DTI_STORY); @@ -1062,6 +1062,108 @@ for (const [name, story, titleSelector, panelSelector, panelKey] of [ }); } +for (const [name, story, titleSelector, calendarSelector, wheelsSelector] of [ + [ + 'DateInput', + STORY, + '[data-title="month-year"]', + '[data-panel="calendar"]', + '[data-panel="wheels"]', + ], + [ + 'DateTimeInput', + DTI_STORY, + 'button[aria-label$="Choose month and year"]', + '[data-date-surface="calendar"]', + '[data-date-surface="wheels"]', + ], +] as const) { + test(`${name}: the wheels fade in on an opaque plate, and the calendar stays drawn beneath them`, async ({ + page, + }) => { + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + const tapCenter = async (selector: string) => { + const box = await page.locator(selector).first().boundingBox(); + if (box == null) { + throw new Error(`no ${selector}`); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter('button[aria-label^="Open "]'); + await settlesAt( + page.getByRole('dialog').locator('.astryx-bottom-sheet').first(), + 'transform', + 'matrix(1, 0, 0, 1, 0, 0)', + ); + const calendar = page.locator(calendarSelector).first(); + const wheels = page.locator(wheelsSelector).first(); + // The panels share one parent, so its watch sees a run on either panel. + const runs = await watchAnimations(calendar.locator('..')); + await tapCenter(titleSelector); + // The wheels fade in. Nothing fades out beneath them. + const fades = (await runs.started()).filter(run => run.name === 'opacity'); + expect(fades.length, JSON.stringify(fades)).toBeGreaterThan(0); + for (const fade of fades) { + expect(String(fade.keyframes[0].opacity), JSON.stringify(fade)).toBe('0'); + } + await waitForRest(wheels); + const state = await page.evaluate( + ({calendarAt, wheelsAt}) => { + const under = document.querySelector<HTMLElement>(calendarAt); + const over = document.querySelector<HTMLElement>(wheelsAt); + if (under == null || over == null) { + throw new Error('no panels'); + } + // The color behind both panels: their nearest ancestor that paints. + let painted = over.parentElement; + while ( + painted != null && + getComputedStyle(painted).backgroundColor === 'rgba(0, 0, 0, 0)' + ) { + painted = painted.parentElement; + } + const underStyle = getComputedStyle(under); + const overStyle = getComputedStyle(over); + const underBox = under.getBoundingClientRect(); + const overBox = over.getBoundingClientRect(); + return { + calendar: { + opacity: underStyle.opacity, + visibility: underStyle.visibility, + ariaHidden: under.getAttribute('aria-hidden'), + }, + plate: { + color: overStyle.backgroundColor, + isolation: overStyle.isolation, + covers: + overBox.left <= underBox.left + 0.5 && + overBox.top <= underBox.top + 0.5 && + overBox.right >= underBox.right - 0.5 && + overBox.bottom >= underBox.bottom - 0.5, + }, + sheetColor: + painted == null ? null : getComputedStyle(painted).backgroundColor, + }; + }, + {calendarAt: calendarSelector, wheelsAt: wheelsSelector}, + ); + // Still drawn at full opacity, but out of the accessibility tree. + expect(state.calendar).toEqual({ + opacity: '1', + visibility: 'visible', + ariaHidden: 'true', + }); + // The wheels cover it with a plate in the sheet's own color. + expect(state.sheetColor).not.toBeNull(); + expect(state.plate).toEqual({ + color: state.sheetColor, + isolation: 'isolate', + covers: true, + }); + }); +} + test.describe('with a mouse', () => { test.use({hasTouch: false, isMobile: false}); diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index dc461c2829bec..9ac6147ae73a9 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -466,6 +466,21 @@ const styles = stylex.create({ reducedMotionFadeConsts.easing, }, }, + /** + * The wheels cover the calendar with an opaque plate in the sheet's color, + * so only they fade and the calendar beneath stays drawn. `isolation` keeps + * the calendar's text from painting through the plate at full opacity, + * where no stacking context forms. + */ + touchWheelsSurface: { + backgroundColor: colorVars['--color-background-surface'], + isolation: 'isolate', + }, + // The calendar never fades: the wheels' plate covers it. With a mouse it + // takes `hidden` when the wheels show. + touchCalendarSurfaceHidden: { + visibility: 'hidden', + }, // Under a finger, UIDatePicker's chevron: it points along the reading // direction for the calendar and turns down on the toggle's clock. With a // mouse it points down and stays. Under Reduced Motion it does not turn. @@ -1269,7 +1284,11 @@ export function TouchDateTimeField({ inert={isCalendarInert ? true : undefined} {...stylex.props( styles.touchDateSurface, - isWheelOpen && styles.touchDateSurfaceHidden, + // Under a finger the calendar stays drawn beneath the opaque + // wheels, and `aria-hidden` and `inert` keep it out of reach. + // Flipping the inherited `visibility` would restyle it in the + // toggle's commit. + isWheelOpen && !isTouch && styles.touchCalendarSurfaceHidden, )}> <div aria-hidden="true" {...stylex.props(styles.touchWeekdays)}> {weekdayHeaders.map(({dayOfWeek, name}) => ( @@ -1321,6 +1340,7 @@ export function TouchDateTimeField({ inert={isWheelsInert ? true : undefined} {...stylex.props( styles.touchDateSurface, + styles.touchWheelsSurface, !isWheelOpen && styles.touchDateSurfaceHidden, )}> <div From a277007901a881f296dadd0ace11154f5af1cbde Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 02:34:21 -0800 Subject: [PATCH 38/40] chore(DateInput): split the toggle comments into shorter sentences Four comment sentences in the month/year toggle's code and tests ran past 25 words. Each now reads as two sentences, with the same meaning. The toggle spec's header also names the new case on the calendar beneath the wheels. --- packages/core/src/DateInput/DateInputTouch.test.tsx | 6 +++--- packages/core/src/DateInput/TouchDateField.tsx | 6 +++--- .../__tests__/MonthYearToggle.interaction.browser.spec.ts | 7 ++++--- packages/core/src/DateInput/useMonthYearToggle.ts | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 2e3ff193b6ca7..21ddccfdb038a 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -3970,9 +3970,9 @@ describe('DateInput — scroll CSS (definition-level)', () => { }); /** - * Everything in the swap runs for the same time, so it reads as one change - * rather than several, the chevron included: today's token with a mouse, - * the toggle's token under a finger. + * Everything in the swap runs for the same time, the chevron included, so it + * reads as one change. That time is today's token with a mouse, and the + * toggle's token under a finger. */ it('runs the whole swap on one duration', () => { const source = read('TouchDateField.tsx'); diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 30c4aacfad986..2dfeaf91c2803 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -1397,9 +1397,9 @@ export function TouchDateField({ icon="chevronDown" size="sm" color="secondary" - // The transform rides on the Icon itself, not a wrapper span, so - // the element a theme can target is the element that moves (and, - // under reduced motion on a touch screen, dips: useMonthYearToggle). + // The transform rides on the Icon itself, not a wrapper span, so a + // theme targets the element that moves. Under reduced motion on a + // touch screen it dips instead (useMonthYearToggle). // StyleX's types do not model the touch key's nested RTL // condition, so the styles are cast; they compile as written. xstyle={ diff --git a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts index 77e67ba80ca06..3e07cd4b9ff71 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -4,9 +4,10 @@ * @file MonthYearToggle.interaction.browser.spec.ts * @input The built "Bottom Sheet Calendar" DateInput story, real touch input * and the interaction harness - * @output Interaction proofs that the calendar's month/year toggle runs on - * the theme's tokens under a finger, in Chromium and WebKit: the fades, - * the chevron, a turnaround, a blocked main thread, Reduced Motion and RTL + * @output Interaction proofs, in Chromium and WebKit, that the calendar's + * month/year toggle runs on the theme's tokens under a finger. They cover + * the fades, the chevron, a turnaround, a blocked main thread, Reduced + * Motion, RTL and the calendar beneath the wheels * @position Interaction tests of TouchDateField.tsx, TouchDateTimeField.tsx and * MonthPager.tsx (`*.interaction.browser.spec.ts`) */ diff --git a/packages/core/src/DateInput/useMonthYearToggle.ts b/packages/core/src/DateInput/useMonthYearToggle.ts index f1fe45dfec95f..1f89c81d52e04 100644 --- a/packages/core/src/DateInput/useMonthYearToggle.ts +++ b/packages/core/src/DateInput/useMonthYearToggle.ts @@ -107,8 +107,8 @@ interface MonthYearToggleOptions { * `inert` is inherited, so flipping it restyles a panel's whole subtree. * Under a finger both flips wait for the cross-fade to end, when nothing on * screen moves. Meanwhile the caller's `aria-hidden` keeps assistive - * technology out of the outgoing panel, and the caller's shield keeps touches - * off both, as a UIKit view animation does. `toggle` moves focus inside the + * technology out of the outgoing panel. The caller's shield keeps touches off + * both, as a UIKit view animation does. `toggle` moves focus inside the * outgoing panel into the incoming one in the toggle's commit. * * Under Reduced Motion on a touch screen the title's chevron does not turn. From 02952803bafd5cbd2c60a4dfa1fc5a5014e93067 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 02:34:30 -0800 Subject: [PATCH 39/40] docs(DateInput): one month/year toggle look for both touch fields FR25 now states that in both touch fields the wheels fade over the calendar, on an opaque layer in the sheet's color. The calendar stays drawn beneath them, hidden from assistive technology. The FR25 verification row names the new test for both fields. The DateTimeInput FR15 row follows the renamed toggle test. --- docs/specs/components/DateInput/DateInput.spec.md | 4 ++-- docs/specs/components/DateTimeInput/DateTimeInput.spec.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index 81ca2d5e64ef7..ec6a355c9756d 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -191,7 +191,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR22 | In the touch sheet, a month arrow at the range end is hidden; if it held focus, focus moves first to the other month arrow, or to the month-and-year button when both are hidden (`spec:AST-085/FR4`). | `spec:AST-085/FR4`; current source and sheet arrow tests | | FR23 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | | FR24 | Under a coarse primary pointer, the bottom-sheet calendar's title chevron MUST point in the reading direction while the calendar shows and turn a quarter turn to point down while the month/year wheels show, on the theme duration token nearest UIKit's measured 250 ms in the Storybook theme (`--duration-medium-min`, 225 ms there) and the move easing (`--ease-move`), as `spec:AST-094` FR45 asks. Under Reduced Motion it does not turn: it dips to transparent and back, and the turn lands at the dip's middle. | UIDatePicker on the iOS 26.2 simulator; `motion/uikit/datePicker.ts` `calendar.monthYearToggle` | -| FR25 | Under a coarse primary pointer, while the bottom-sheet calendar's month/year toggle cross-fades, a tap MUST reach neither panel: the outgoing panel and the header's and footer's actions that leave with it stop taking taps with the toggle, and the incoming ones take taps once the outgoing panel's fade ends. Under a fine pointer they swap with the toggle. When the title or Done toggles while focus is inside the outgoing panel, focus MUST move into the incoming panel once that panel can take focus, onto the shown month's tab stop in the calendar; focus outside the panels stays where it is. The internal `useMonthYearToggle` hook gives both touch fields this behavior and FR24's chevron dip. | UIDatePicker on the iOS 26.2 simulator, whose toggle takes no touch mid-fade; the APG grid's single tab stop | +| FR25 | Under a coarse primary pointer, while the bottom-sheet calendar's month/year toggle cross-fades, a tap MUST reach neither panel: the outgoing panel and the header's and footer's actions that leave with it stop taking taps with the toggle, and the incoming ones take taps once the toggle's fade ends. Under a fine pointer they swap with the toggle. Under a coarse pointer the wheels fade over the calendar, in both touch fields. They cover it with an opaque layer in the sheet's color, and the calendar stays drawn beneath them, hidden from assistive technology. When the title or Done toggles while focus is inside the outgoing panel, focus MUST move into the incoming panel once that panel can take focus, onto the shown month's tab stop in the calendar; focus outside the panels stays where it is. The internal `useMonthYearToggle` hook gives both touch fields this behavior and FR24's chevron dip. | UIDatePicker on the iOS 26.2 simulator, whose toggle takes no touch mid-fade; the APG grid's single tab stop | ### Allowed variation @@ -423,7 +423,7 @@ clear-glyph target of its own. | FR22 | `DateInputTouch.test.tsx` "moves focus to the other arrow when the focused arrow reaches the end of the range and hides" and "moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus" | Sheet month arrows at the first and last month of a range | Focus left on a hidden arrow, or dropped to the document body, fails. | `audit:DateInput/behavior` | | FR23 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | | FR24 | `MonthYearToggle.interaction.browser.spec.ts` "the wheels fade in and the chevron turns on the theme’s tokens nearest UIKit’s toggle", "in RTL the chevron points left, and turns to point down", "Reduced Motion: the wheels cross-fade in at most 200 ms and the chevron dips instead of turning" | The toggle in LTR and RTL; Reduced Motion | A chevron that points against the reading direction, turns on another clock, or turns under Reduced Motion instead of dipping fails. | `audit:DateInput/behavior` | -| FR25 | `useMonthYearToggle.test.tsx` "makes the outgoing panel inert when its fade ends, never with the toggle", "swaps inert with the toggle with a mouse, fade or not", "moves focus inside the outgoing panel to the incoming one in the toggle’s commit", "leaves focus outside the panels where it is"; `MonthYearToggle.interaction.browser.spec.ts` "a tap on Done during the opening fade does not reach Save beneath it", "DateTimeInput: a tap on the month arrows during the opening fade changes nothing", "DateInput: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends"; `DateInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit", "Done hands focus from the wheels to the calendar in the toggle’s commit"; `DateTimeInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit" | A tap mid-fade on Done, the arrows or a panel; a toggle with focus in either panel; a mouse toggle | A tap that reaches a panel or an incoming action before the fade ends, an outgoing action that still takes taps after the toggle, or focus left in a fading panel fails. | `audit:DateInput/behavior` | +| FR25 | `useMonthYearToggle.test.tsx` "makes the outgoing panel inert when its fade ends, never with the toggle", "swaps inert with the toggle with a mouse, fade or not", "moves focus inside the outgoing panel to the incoming one in the toggle’s commit", "leaves focus outside the panels where it is"; `MonthYearToggle.interaction.browser.spec.ts` "a tap on Done during the opening fade does not reach Save beneath it", "DateTimeInput: a tap on the month arrows during the opening fade changes nothing", "DateInput: the toggle's commit flips no inherited property on the panels; they swap inert when the fade ends", "DateInput: the wheels fade in on an opaque plate, and the calendar stays drawn beneath them", "DateTimeInput: the wheels fade in on an opaque plate, and the calendar stays drawn beneath them"; `DateInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit", "Done hands focus from the wheels to the calendar in the toggle’s commit"; `DateTimeInputTouch.test.tsx` "a title press hands focus from the calendar to the wheels in the toggle’s commit" | A tap mid-fade on Done, the arrows or a panel; a toggle with focus in either panel; a mouse toggle; the wheels over the calendar in both fields | A tap that reaches a panel or an incoming action before the fade ends, an outgoing action that still takes taps after the toggle, focus left in a fading panel, or a calendar that fades or shows through the wheels fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index 07f4026c1480d..41e443e05274c 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -316,7 +316,7 @@ leading clock glyph uses `date-time-input-clock-icon`. | FR12 | `DateInput.calendarPresentation.test.tsx` DateTimeInput cases (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"; "writes the sheet date segment in %s %s with its era" for `en` and `fa-IR` Persian and `th-TH` Buddhist); `DateInputTouch.calendar.test.tsx` "shows Persian months and the date in the calendar"; `DateInput.noTemporal.test.tsx` "shows a Persian DateTimeInput as Gregorian native controls" | Persian on fine and coarse pointers, explicit `native`, no `Temporal` | A native date or time control for a non-Gregorian calendar, a missing warning for an explicit `native`, or a Date panel in another calendar, fails | `audit:DateTimeInput/surfaces` | | FR13 | `DateTimeInput.test.tsx` "typed text while the field is disabled or busy" cases | Loading, disabled, and saves; unfinished, valid, reported, rejected, new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateTimeInput/state` | | FR14 | `DateTimeInputTouch.test.tsx` "under a finger: moves the chosen date with the month wheel, keeping the day and the time", "under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)", "under a finger: opening the month/year wheels on a date outside the range changes nothing", "under a finger: keeps a date a custom constraint refuses, while the calendar moves" | A month turn with a time; a pending Action; opened out of range; a refused date | A wheel that drops the time, leaves the date behind, changes the value on opening, or chooses a refused date fails. | `audit:DateTimeInput/surfaces` | -| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the calendar and the wheels cross-fade on the theme’s motion tokens, the title chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus", "DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | +| FR15 | `MonthYearToggle.interaction.browser.spec.ts` "DateTimeInput: the wheels fade in over the calendar on the theme’s motion tokens, the title chevron turning with them", "DateTimeInput: Done hands focus to the shown month’s day once the calendar can take focus", "DateTimeInput: Done hands focus to the shown month’s day, not to a month mounted off-screen"; `DateTimeInputTouch.test.tsx` "Done hands focus from the wheels to the calendar in the toggle’s commit" | The toggle; Done with focus in the wheels | A chevron that turns otherwise, or Done that drops focus to the page or onto an off-screen month, fails. | `audit:DateTimeInput/surfaces` | | Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` | ## Current-state impact From 6c86a5a0834022a83c35474ea54d74ff3417fc06 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau <vjeuxx@gmail.com> Date: Wed, 11 Nov 2026 03:29:05 -0800 Subject: [PATCH 40/40] chore(DateInput): keep TouchDateField's imports above its code The reduced-motion constants' import sat below the module's first statement. It now sits with the other imports. No behavior change. --- packages/core/src/DateInput/TouchDateField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 2dfeaf91c2803..7a0b44217fdfd 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -123,6 +123,7 @@ import { import {formatCalendarDateText} from '../utils/calendarDateParser'; import {chainEventHandlers} from '../utils/chainEventHandlers'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {normalizeDayOfWeek} from '../utils/dateTypes'; import {getWeekdayHeaders} from '../Calendar/getWeekdayHeaderNames'; @@ -149,7 +150,6 @@ import english from '../i18n/generated-english/DateInput/TouchDateField.generate const UNKNOWN_TODAY_STAND_IN = plainDateFromISO('1970-01-01'); registerEnglishFallback(english); -import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; /** * The comfortable minimum tap target on both iOS and Android, honoured by