diff --git a/.changeset/calendar-pages-like-uidatepicker.md b/.changeset/calendar-pages-like-uidatepicker.md new file mode 100644 index 0000000000000..51541a42cb014 --- /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. +- 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. + +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/.changeset/calendar-toggle-uidatepicker-timing.md b/.changeset/calendar-toggle-uidatepicker-timing.md new file mode 100644 index 0000000000000..53be70dbac06f --- /dev/null +++ b/.changeset/calendar-toggle-uidatepicker-timing.md @@ -0,0 +1,17 @@ +--- +'@astryxdesign/core': patch +--- + +[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. 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. + +With a mouse or a pen, the toggle stays as it is. + +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. + +@vjeux diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index fe66f226135db..60e8ed5c2246d 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -33,6 +33,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( + '2026-03-15' as ISODateString, + ); + const [dateTime, setDateTime] = useState( + undefined, + ); + return ( + + + Month and year toggle + + Tap a field, then tap the month title in its sheet. + + + + + + {ROWS.map(row => ( + + {row} + + ))} + + + ); +} diff --git a/apps/sandbox/src/app/sandboxPages.ts b/apps/sandbox/src/app/sandboxPages.ts index a08da905e5cd2..f0d2f543b8340 100644 --- a/apps/sandbox/src/app/sandboxPages.ts +++ b/apps/sandbox/src/app/sandboxPages.ts @@ -200,6 +200,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', + }, ], }, { diff --git a/apps/storybook/stories/DateInput.stories.tsx b/apps/storybook/stories/DateInput.stories.tsx index 005ba5edd5f86..41e97ed51a7a7 100644 --- a/apps/storybook/stories/DateInput.stories.tsx +++ b/apps/storybook/stories/DateInput.stories.tsx @@ -594,6 +594,25 @@ export const WithMinMax: Story = { }, }; +/** + * 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 => { + 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/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/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/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index e95debfad39c8..ceb18865f0f5b 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -68,15 +68,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. @@ -90,8 +90,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 @@ -127,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`"). @@ -177,7 +182,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 | @@ -192,6 +197,8 @@ 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 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 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 @@ -254,8 +261,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 @@ -292,9 +297,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 @@ -308,15 +311,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 @@ -406,15 +409,15 @@ 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` | | 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"); `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 | 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` | @@ -426,6 +429,8 @@ 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 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", "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 @@ -441,11 +446,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). -- **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. +- **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/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index edd028934cde1..2861c4a80e558 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. Its minute wheel loops as `component:TimeInput` FR7 states: passing the seam keeps the hour. | `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, 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 @@ -315,6 +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"; "keeps the hour and the date as the minute wheel passes its seam at 11:59 PM" | 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 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 diff --git a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx index 10145eedd6999..a5f2bf9dde255 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 './MonthScroller'; 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); diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index f5331be5c693f..21ddccfdb038a 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'; @@ -58,9 +59,51 @@ import { DEFAULT_MONTH_REACH, } from './monthGeometry'; import {WHEEL_ROW_HEIGHT} from './wheelOptions'; +import {datePicker} from '../motion/uikit/datePicker'; +import {expandMotionScale} from '../theme/expandMotionScale'; 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 {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. +vi.mock('../Calendar/hooks/useCalendarDays', async importOriginal => { + const actual = await 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)}; +}); +// 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(); + const {memo} = await import('react'); + return {...actual, MonthPager: memo(vi.fn(actual.MonthPager.type))}; +}); // --------------------------------------------------------------------------- // jsdom scaffolding @@ -136,8 +179,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}); @@ -181,6 +225,7 @@ function withLayout(fn: () => T): T { } } +/** As `withLayout`, around work that waits on timers. */ beforeAll(() => { Element.prototype.scrollTo = vi.fn(); // jsdom's implements none of these. @@ -967,6 +1012,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 +1118,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 = () => @@ -1071,9 +1217,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'}); @@ -1268,21 +1424,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 +1640,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 +1868,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 +1945,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( @@ -2170,6 +2379,149 @@ 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 (useMonthYearToggle). + 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') => { + // 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)); + }); + }; + + // 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('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( + { + commits += 1; + }}> + + , + ); + 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('[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('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( + { + commits += 1; + }}> + + , + ); + 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(); openWheels(); @@ -2336,8 +2688,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 +2714,184 @@ 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(); + }); + + /** + * 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); + }; + // 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); + }); + + 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', () => { + 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', () => { + 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 +2905,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,12 +3396,97 @@ 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(); + // 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(); + }); + + 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(); + 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: 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( + , + ); + const title = () => + document.querySelector('[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', () => { + // 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( + , + ); + const title = () => + document.querySelector('[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 + // 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. + const paneBlockSize = /blockSize: dateInputTouchGeometry\.paneBlockSize/g; + expect(source.match(paneBlockSize)).toHaveLength(1); + expect(read('MonthScroller.tsx').match(paneBlockSize)).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 - // 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. + // 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('), @@ -3143,7 +3759,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 @@ -3198,10 +3814,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'); } }); @@ -3256,25 +3870,109 @@ describe('DateInput — scroll CSS (definition-level)', () => { styles.indexOf(' titleChevronOpen: {'), ); expect(chevron).toContain("transitionProperty: 'transform'"); - expect(chevron).toContain( - "transitionTimingFunction: easeVars['--ease-standard']", - ); - // The chevron is the only eased transition; every fade is linear. + expect(chevron).toContain("default: easeVars['--ease-standard']"); + // The chevron is the only eased transition; every fade is linear, in its + // shown and its hidden style. 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(8); }); /** - * 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. + * 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 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 in on the + // entrance curve. Their hidden styles carry the exit curve: one touch key + // each. + expect( + 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_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). + const turn = datePicker.calendar.monthYearToggle.chevronTurnDegrees; + expect(chevron).toContain(`default: 'rotate(-${turn}deg)'`); + expect(chevron).toContain( + `':is([dir="rtl"] *)': 'scaleX(-1) rotate(-${turn}deg)'`, + ); + const open = declarations(source, 'titleChevronOpen'); + expect(open).toContain("default: 'rotate(0deg)'"); + expect(open).toContain("':is([dir=\"rtl\"] *)': 'scaleX(-1) rotate(0deg)'"); + expect(source).toContain( + "const TOGGLE_DURATION = durationVars['--duration-medium-min']", + ); + 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, + ]); + // 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)`); + } + }); + + /** + * 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'); @@ -3283,12 +3981,145 @@ 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 = durationVars['--duration-medium-min']", + ); + }); + + 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('MonthScroller.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']", + ); + // MonthScroller.tsx also defines the prop, so this matches the attribute. + expect(read('MonthScroller.tsx')).toMatch(/^\s+hasSelectionFade$/m); + 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( + '[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( + , + ); + const day = pane('March 2026').querySelector( + '[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, + // 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_ENTRANCE"); + expect(declarations(source, `${object}Hidden`)).toContain( + "'@media (pointer: coarse)': TOGGLE_EXIT", + ); + } + const chevron = declarations(source, 'touchTitleChevron'); + expect(chevron).toContain("'@media (pointer: coarse)': TOGGLE_TURN"); + expect(declarations(source, 'touchTitleChevronOpen')).toContain( + "default: 'rotate(0deg)'", + ); + expect(source).toContain('ref={titleRef}'); + }); + + 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'", + ); + // The turn lands at the dip's middle (useMonthYearToggle), not on a delay. + expect(chevron.includes('transitionDelay')).toBe(false); + expect(source).toContain('useMergedRefs(monthYearButtonRef, titleRef)'); }); it('keeps the virtual keyboard down on the touch field', () => { diff --git a/packages/core/src/DateInput/MonthPager.tsx b/packages/core/src/DateInput/MonthPager.tsx new file mode 100644 index 0000000000000..0cb2c65a192c9 --- /dev/null +++ b/packages/core/src/DateInput/MonthPager.tsx @@ -0,0 +1,343 @@ +// 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, + useRef, + useState, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {usePager} from '../hooks/usePager'; +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; +import {observeResize} from '../utils/sharedResizeObserver'; +import {dateInputTouchGeometry} from './tokens.stylex'; +import { + MonthPane, + useMonthGridFocus, + type MonthScrollerProps, +} from './MonthScroller'; +import {useOwnScrollGesture} from './useOwnScrollGesture'; +import {clampIndex, paneWindow, rowsIn} from './monthGeometry'; + +/** + * 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; + +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', + }, + /** 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', + }, +}); + +/** + * 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); + // 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); + + // Read through a ref, so a caller need not memoize the callback. + const onVisibleMonthChangeRef = useRef(onVisibleMonthChange); + 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 (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 => + Math.abs(recognizer.totalTranslation().x) >= + UIKIT_GESTURES.pan.hysteresis.value, + }, + }); + + // 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; + } + 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) { + setPaneSize(measured); + } + }); + }, []); + + // 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; + if (frames > 2) { + report( + clampIndex(pager.nearestPage(pager.presentation()), 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') { + 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 + // 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], + ); + + // 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); + 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]); + + // 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). + useOwnScrollGesture(viewportRef, 'pager'); + + const {focusedDate, handleKeyDown, handleDayFocus} = useMonthGridFocus( + viewportRef, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, + }, + ); + + const visibleRows = paneWindow(centerRow, rowCount, OVERSCAN); + + 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'; diff --git a/packages/core/src/DateInput/MonthScroller.tsx b/packages/core/src/DateInput/MonthScroller.tsx index c0e685e424d28..e0d29c6a95460 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,6 +34,7 @@ */ import { + memo, useCallback, useEffect, useImperativeHandle, @@ -39,6 +42,8 @@ import { useRef, useMemo, useState, + type KeyboardEvent, + type RefObject, } from 'react'; import {flushSync} from 'react-dom'; import * as stylex from '@stylexjs/stylex'; @@ -69,6 +74,9 @@ import { type ReducedMotionDip, } from '../motion/reducedMotion'; import { + type CalendarSystem, + calendarShiftMonths, + calendarShiftYears, calendarYearMonthOf, formatCalendarDate, formatCalendarMonthLabel, @@ -85,8 +93,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 @@ -99,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', @@ -127,15 +135,18 @@ 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. + }, + pane: { + position: 'absolute', + insetBlock: 0, + blockSize: dateInputTouchGeometry.paneBlockSize, display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateRows: dateInputTouchGeometry.paneRows, @@ -188,6 +199,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'], }, @@ -198,12 +216,10 @@ const styles = stylex.create({ // 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. + // 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)': { @@ -231,38 +247,23 @@ const styles = stylex.create({ 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. + * 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 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. + * 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: { - // 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.) + // 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: { - // 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. + // 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', }, @@ -272,19 +273,21 @@ 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). + // 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. */ 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; @@ -402,12 +405,6 @@ export function MonthScroller({ */ const steer = useScrollSteer(); - // 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; @@ -750,98 +747,20 @@ export function MonthScroller({ }; }, [paneSize, rowCount, minMonthIndex, isRTL, recenter, steer]); - // 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. - 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'); - } - }, - [monthModel, minMonthIndex, maxMonthIndex, scrollToMonth], - ); - - 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; - default: - break; - } + const {focusedDate, handleKeyDown, handleDayFocus} = useMonthGridFocus( + scrollerRef, + { + monthModel, + minMonthIndex, + maxMonthIndex, + weekStartsOn, + scrollToMonth, + onVisibleMonthChangeRef, }, - [isRTL, 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 (
))}
@@ -874,7 +796,8 @@ export function MonthScroller({ MonthScroller.displayName = 'MonthScroller'; -interface MonthPaneProps { +export interface MonthPaneProps { + /** The calendar's months, by index. */ monthModel: MonthModel; monthIndex: number; insetInlineStart: number; @@ -888,16 +811,26 @@ interface MonthPaneProps { 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; + /** Whether a day's selection fades in, as it does with a mouse. */ + hasSelectionFade?: boolean; } /** * 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. + * 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. */ -function MonthPane({ +export const MonthPane = memo(function MonthPane({ monthModel, monthIndex, insetInlineStart, @@ -910,8 +843,16 @@ function MonthPane({ onSelect, onDayKeyDown, onDayFocus, + hasTabStop, + paneStyle, + 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(null); + const isSelectionFade = pointerFade ?? hasSelectionFade; const {calendar} = monthModel; const month = useMemo( () => monthModel.monthAt(monthIndex), @@ -928,9 +869,9 @@ function MonthPane({ 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. + // 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); @@ -948,16 +889,15 @@ function MonthPane({ data-month={monthLabel} {...stylex.props( styles.pane, + paneStyle, dynamic.pane(insetInlineStart, inlineSize), )}> {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. + // 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 && @@ -967,6 +907,7 @@ function MonthPane({ !day.isOutside && today != null && plainDateIsEqual(day.date, today); + const isTabStop = hasTabStop && day.iso === tabbableISO; return (
{ + setPointerFade( + event.pointerType === 'mouse' || + event.pointerType === 'pen', + ); + }} onClick={() => { if (!isDisabled) { 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, focusOutlineStyles.focusVisible, - // Outside FIRST, so disabled wins where both apply: a - // spilled day past the range is unselectable, and must - // not be painted more available than the in-month days - // beside it. + // 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, )}> ); -} +}); 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, + { + 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}; +} diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index b008859348884..7a0b44217fdfd 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'; @@ -122,11 +123,14 @@ 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'; -import {useTouchPresentation} from '../utils/touchPresentation'; +import {MonthPager} from './datePickerTouch'; +import {useMonthYearToggle} from './useMonthYearToggle'; import {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; +import {useTouchPresentation} from '../utils/touchPresentation'; import {MonthYearWheels} from './MonthYearWheels'; import { DEFAULT_MONTH_REACH, @@ -171,6 +175,17 @@ const TOUCH_TARGET = dateInputTouchSizes.daySize; * and not half of one. */ const SWAP_DURATION = durationVars['--duration-fast']; +/** + * 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 = durationVars['--duration-medium-min']; +const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; +const TOGGLE_EXIT = easeVars['--ease-exit']; +const TOGGLE_TURN = easeVars['--ease-move']; const sizeStyles = stylex.create({ sm: { @@ -278,11 +293,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 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: 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_ENTRANCE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, /** @@ -308,6 +336,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 @@ -335,17 +369,36 @@ const styles = stylex.create({ headerReset: { display: 'flex', alignItems: 'center', + // 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: 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_ENTRANCE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, headerResetHidden: { 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 @@ -393,19 +446,46 @@ 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. + // 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. A test holds the quarter turn to the UIKit + // table (datePicker.calendar.monthYearToggle.chevronTurnDegrees). + transform: { + default: 'none', + '@media (pointer: coarse)': { + 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(-90deg)', + }, + }, 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 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', + }, + transitionTimingFunction: { + default: easeVars['--ease-standard'], + '@media (pointer: coarse)': TOGGLE_TURN, }, }, titleChevronOpen: { - transform: 'rotate(180deg)', + transform: { + default: 'rotate(180deg)', + '@media (pointer: coarse)': { + default: 'rotate(0deg)', + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)', + }, + }, }, weekdays: { display: 'grid', @@ -414,11 +494,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 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: 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_ENTRANCE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, weekdaysHidden: { @@ -426,6 +519,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', @@ -438,6 +537,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 (useMonthYearToggle). 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. */ @@ -466,14 +579,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. @@ -516,17 +636,36 @@ const styles = stylex.create({ panelOverlay: { backgroundColor: colorVars['--color-background-surface'], isolation: 'isolate', + // 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: 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_ENTRANCE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, }, }, panelOverlayHidden: { visibility: 'hidden', opacity: 0, pointerEvents: 'none', + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, footer: { paddingBlockStart: spacingVars['--spacing-2'], @@ -687,10 +826,34 @@ export function TouchDateField({ isSheetOpen ? sheetBodyRef : inputRef, ); const [isWheelOpen, setIsWheelOpen] = useState(false); + // 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(null); + const wheelsPanelRef = useRef(null); + const {isTouch} = useTouchPresentation(); + 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 + // through to Save. + const isCalendarActionsInert = isWheelOpen || isCalendarInert; + const isWheelsActionsInert = !isWheelOpen || isWheelsInert; + const MonthGrid = isTouch ? MonthPager : MonthScroller; const scrollerHandleRef = useRef(null); const previousArrowRef = useRef(null); const nextArrowRef = useRef(null); const monthYearButtonRef = useRef(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, titleRef); // Pending focus handoff from the clear button; see handleClear. const clearFocusTimerRef = useRef(null); // Releases the focus timer on unmount. @@ -979,7 +1142,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); @@ -1019,15 +1181,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], ); /** @@ -1083,13 +1250,129 @@ 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(() => toggle(false), [toggle]); + 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 @@ -1135,45 +1423,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. @@ -1181,20 +1436,12 @@ export function TouchDateField({ keeps its size, so the header cannot change height mid-swap. */} -
@@ -1214,20 +1461,25 @@ export function TouchDateField({ ))}
-
+
- + {monthYearWheels}
@@ -1287,40 +1531,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__/MonthPager.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts new file mode 100644 index 0000000000000..3daea5ef4a5e6 --- /dev/null +++ b/packages/core/src/DateInput/__tests__/MonthPager.interaction.browser.spec.ts @@ -0,0 +1,915 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @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, 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`) + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + animationStoryboard, + armHold, + busyWindowProof, + curveOf, + drag, + flick, + freezeTimers, + installTimerGate, + markElement, + openStory, + releaseAnimations, + sampleCurve, + startScreencast, + thawTimers, + timeline, + touchDriver, + trackMarker, + waitForRest, + webInputDriver, + withStorybook, + writeEvidence, + WHEEL_SKIP, + type TouchDriver, + type TouchScript, +} from '@astryxdesign/interaction-harness'; +import {paging} from '../../motion/uikit/paging'; + +const storybook = withStorybook(test); + +const STEP_MS = paging.animatedScroll.durationMs; + +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'; + +/** + * 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} = {}, +): 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 settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); + const rect = await viewport.boundingBox(); + if (rect == null) { + throw new Error('no viewport'); + } + return { + driver, + sheet, + viewport, + // 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}, + }; +} + +/** The content offset in months: 0 at January in this story. */ +async function monthsIn(viewport: Locator): Promise { + return viewport.evaluate(element => { + const track = element.firstElementChild; + 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(); + // 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, + ), + ).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, + browserName, +}) => { + const {driver, track, title, center} = await openCalendar(page); + await installTimerGate(page); + // About a quarter of a month at 2500 px/s: the release pages, short of + // half. + await driver.play( + liftWithLastMove( + flick(center, {velocity: {x: -2500, y: 0}, durationMs: 40, hz: 120}), + ), + ); + // 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'); + await thawTimers(page); + await waitForRest(track); + // 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}) => { + 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 one month', 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'); + } + // 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, { + 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(); + await freezeTimers(page); + 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); + // 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(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); +}); + +/** + * `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, + 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. 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; + new MutationObserver(() => { + const text = element.textContent ?? ''; + if (seen[seen.length - 1] !== text) { + seen.push(text); + } + }).observe(element, {subtree: true, characterData: true, childList: true}); + }); + // 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, { + 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'); + await releaseAnimations(page); + 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.firstElementChild; + 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. + 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 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. + 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 step'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` while the page records, every frame, the sheet's drop and + * 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) => { + const months = document.querySelector('[data-scroller="months"]'); + const track = months?.firstElementChild; + const seen = { + 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) { + 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; + sheetMoves: number; + frame: number; + stop: () => void; + }; + }; + cancelAnimationFrame(splitSeen.frame); + splitSeen.stop(); + return { + sheetDrop: splitSeen.sheetDrop, + monthsMin: splitSeen.monthsMin, + monthsMax: splitSeen.monthsMax, + sheetMoves: splitSeen.sheetMoves, + }; + }); + }; + + // 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.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: 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); + 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(restsOn, 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'); + } + // 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, { + 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); + 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, + 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); + // The page holds the settle as soon as it runs, as for the arrow step. + 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); + 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)}), + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + 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: viewport.locator(':scope > *'), + 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/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts index 55329da3d855c..68eb19f4df8ba 100644 --- a/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthScroller.interaction.browser.spec.ts @@ -2,12 +2,12 @@ /** * @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`) */ @@ -18,11 +18,7 @@ import { type Locator, type Page, } from '@astryxdesign/a11y-spec/playwright'; -import { - openStory, - touchDriver, - withStorybook, -} from '@astryxdesign/interaction-harness'; +import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; const storybook = withStorybook(test); const STORY = 'core-dateinput--default'; @@ -31,22 +27,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. @@ -82,7 +70,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 => @@ -122,7 +110,7 @@ test.describe('with a mouse', () => { test('Tab right after an arrow across a month edge leaves the focused day on screen', async ({ page, }) => { - const viewport = await openCalendar(page, 'mouse', 'no-preference'); + const viewport = await openCalendar(page, 'no-preference'); const title = page.locator('[data-title="month-year"]'); const shown = (await title.textContent())?.trim() ?? ''; // ArrowRight on the shown month's last day crosses into the next month @@ -159,7 +147,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); @@ -191,119 +179,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..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,12 +5,11 @@ * @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 - * 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. + * @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 * 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/__tests__/MonthYearToggle.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts new file mode 100644 index 0000000000000..3e07cd4b9ff71 --- /dev/null +++ b/packages/core/src/DateInput/__tests__/MonthYearToggle.interaction.browser.spec.ts @@ -0,0 +1,1286 @@ +// 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, 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`) + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + animationStoryboard, + armHold, + blockMainThread, + busyWindowProof, + freezeTimers, + installTimerGate, + markElement, + openStory, + releaseAnimations, + startScreencast, + thawTimers, + timeline, + touchDriver, + waitForRest, + watchAnimations, + webInputDriver, + withStorybook, + writeEvidence, + type ActiveAnimation, + type CastFrame, + type TrackedFrame, +} from '@astryxdesign/interaction-harness'; +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> = { + 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 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; + exit: ReadonlyArray; + move: ReadonlyArray; +} + +/** + * 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 { + 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 { + 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: `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, + property: string, + [from, to]: readonly [string, string], + {ms, ease}: {ms: number; ease: ReadonlyArray}, +) { + 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(ms); + const points = bezierOf(run.easing); + ease.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'; + +/** + * 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, 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 settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); + const wheels = page.locator('[data-panel="wheels"]').first(); + 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 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'], { + ms: tokens.ms, + ease: tokens.entrance, + }); + 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 ({ + 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 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); + 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 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'], { + ms: tokens.ms, + ease: tokens.entrance, + }); + 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 ({ + 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 + // 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; }', + }); + // 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 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).toBeLessThan( + (1.5 * steepest * Math.max(50, time - lastTime)) / 2000, + ); + } + 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 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(); + 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 + tokens.ms)}; + expect(spanned.to - spanned.from).toBeGreaterThan(0.6 * tokens.ms); + 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 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); + expect(chevron.length).toBeGreaterThan(0); + for (const animation of chevron) { + expect(animation.properties).toEqual(['opacity']); + expect(animation.duration).toBeLessThanOrEqual(200); + } + 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 ({ + 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 () => + 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(tokens.ms + 100); + expect(await pointing()).toEqual({x: 0, y: 1}); +}); + +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(); + 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 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', + ); + 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. */ +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, + }; + }); +} + +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 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}) => { + 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(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. + 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(tokens.ms); + }); +} + +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(calendarAt); + const over = document.querySelector(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}); + + 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'}); + 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); + 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 the toggle's fade on a loaded runner. + const hold = await armHold(page, sheet, { + judges: {target: wheels, property: 'opacity'}, + }); + await driver.tap(titleAt); + const held = await hold.held(); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'month-year-toggle', + clip: sheet, + held, + times: timeline(tokens.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 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/useMonthYearToggle.test.tsx b/packages/core/src/DateInput/useMonthYearToggle.test.tsx new file mode 100644 index 0000000000000..09a97b80658ba --- /dev/null +++ b/packages/core/src/DateInput/useMonthYearToggle.test.tsx @@ -0,0 +1,380 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {act, fireEvent, render} from '@testing-library/react'; +import {useLayoutEffect, useRef, useState} from 'react'; +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()), + playReducedMotionDip: vi.fn(), +})); + +const {playReducedMotionDip} = reducedMotion; + +function Panels({ + isWheelOpen, + isTouch = true, + onCommit, + onToggle, +}: { + isWheelOpen: boolean; + isTouch?: boolean; + onCommit?: () => void; + /** 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. + useLayoutEffect(() => { + onCommit?.(); + }); + const calendarRef = useRef(null); + const wheelsRef = useRef(null); + const {isCalendarInert, isWheelsInert, toggle} = useMonthYearToggle({ + isWheelOpen, + setIsWheelOpen: next => onToggle?.(next), + isTouch, + calendarRef, + wheelsRef, + }); + return ( + <> + {onToggle != null && ( + + )} +
+ +
+
+
+
+ + ); +} + +/** Panels behind a toggle that owns the state. */ +function TogglePanels({onCommit}: {onCommit?: () => void}) { + const [isWheelOpen, setIsWheelOpen] = useState(false); + return ( + + ); +} + +/** 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[]; + } +} + +/** 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(() => { + element.dispatchEvent(event); + }); + // The swap lands one frame after the end. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); +} + +describe('useMonthYearToggle: inert and focus', () => { + it('makes the outgoing panel inert when its fade ends, never with the toggle', async () => { + const view = render(); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + expect(wheels.hasAttribute('inert')).toBe(true); + fading(calendar, wheels); + view.rerender(); + // 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('does not settle at the end of a transition that ran before the toggle', async () => { + const view = render(); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(); + // 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(); + const calendar = view.getByTestId('calendar'); + calendar.getAnimations = () => + [ + {transitionProperty: 'visibility', playState: 'running'}, + ] as unknown as Animation[]; + view.rerender(); + expect(calendar.hasAttribute('inert')).toBe(false); + transitionRun(calendar, 'visibility'); + 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(); + 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(); + 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(); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(); + 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(); + const calendar = view.getByTestId('calendar'); + const wheels = view.getByTestId('wheels'); + fading(calendar, wheels); + view.rerender(); + // 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(); + fading(view.getByTestId('calendar'), view.getByTestId('wheels')); + view.rerender(); + 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( + , + ); + commits = 0; + view.rerender(); + expect(commits).toBe(1); + view.rerender( + , + ); + expect(commits).toBe(2); + }); + + it('makes it inert with the toggle when no fade runs', () => { + const view = render(); + view.rerender(); + 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 in the toggle\u2019s commit', () => { + let commits = 0; + const view = render( + { + commits += 1; + }} + />, + ); + const calendar = view.getByTestId('calendar'); + fading(calendar, view.getByTestId('wheels')); + calendar.querySelector('button')!.focus(); + 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(); + const toggle = view.getByRole('button', {name: 'Toggle'}); + toggle.focus(); + fireEvent.click(toggle); + expect(document.activeElement).toBe(toggle); + }); +}); + +const CLOSED = 'matrix(0, -1, 1, 0, 0, 0)'; + +function Title() { + const [isOpen, setIsOpen] = useState(false); + const calendarRef = useRef(null); + const wheelsRef = useRef(null); + const {titleRef, toggle} = useMonthYearToggle({ + isWheelOpen: isOpen, + setIsWheelOpen: setIsOpen, + isTouch: true, + calendarRef, + wheelsRef, + }); + return ( + + ); +} + +describe('useMonthYearToggle: the chevron under Reduced Motion on a touch screen', () => { + const dips: {cancel: ReturnType}[] = []; + 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(); + 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/useMonthYearToggle.ts b/packages/core/src/DateInput/useMonthYearToggle.ts new file mode 100644 index 0000000000000..1f89c81d52e04 --- /dev/null +++ b/packages/core/src/DateInput/useMonthYearToggle.ts @@ -0,0 +1,368 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +'use client'; + +/** + * @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 + * + * 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 + * 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; +} + +/** 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') { + return false; + } + return element + .getAnimations() + .some(animation => + EXIT_PROPERTIES.has( + (animation as {transitionProperty?: string}).transitionProperty ?? '', + ), + ); +} + +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; + /** + * 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. + */ + 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. 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; + /** 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. + 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 (!isTouch && 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. Only a transition + // that runs after the toggle counts. One already running ends on its own + // clock. + 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) => { + 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); + } + 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('transitionrun', onRun); + panel.removeEventListener('transitionend', onEnd); + panel.removeEventListener('transitioncancel', onEnd); + } + }; + }, [isFading, isWheelOpen, calendarRef, wheelsRef]); + + // The incoming panel, and the element that held focus at the hand-off. + const pendingFocusRef = useRef<{incoming: HTMLElement; from: Element} | null>( + null, + ); + // 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; + 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, 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 and can take focus. A panel that a `visibility` transition + // shows takes focus only once that transition starts, so it retries. + useIsomorphicLayoutEffect(() => { + const pending = pendingFocusRef.current; + if (pending == null || pending.incoming.inert) { + return; + } + pendingFocusRef.current = null; + const {incoming, from} = pending; + const target = + shownTabStop(incoming) ?? + incoming.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? + 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), []); + + 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, + titleRef, + toggle, + }; + } + const isIncomingLive = isFading && focusedFor === isWheelOpen; + return { + isCalendarInert: isFading + ? settled && !(isIncomingLive && !isWheelOpen) + : isWheelOpen, + isWheelsInert: isFading + ? !settled && !(isIncomingLive && isWheelOpen) + : !isWheelOpen, + isFading, + titleRef, + toggle, + }; +} 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<HTMLDivElement>(null); + useOwnScrollGesture(ref, 'pager'); + return <div data-testid="pager" ref={ref} />; +} + +/** 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<readonly [number, number]>, +) { + 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(<Pager />); + 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(<Pager />); + 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(<Pager />); + 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..61a1a1c7dd2f6 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, @@ -132,8 +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 + * @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( @@ -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/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index 4d764ca90aa0a..ed96fc5cc6157 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, @@ -891,6 +892,192 @@ 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 (useMonthYearToggle). + 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') => { + // 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)); + }); + }; + + // 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('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) => { + 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), + focusedDate: (document.activeElement as HTMLElement | null)?.dataset + .date, + }; + cleanup(); + return result; + }; + 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); + }); + + 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 />); diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index e3200bd140559..9ac6147ae73a9 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'; @@ -59,6 +60,8 @@ import {useTooltip} from '../Tooltip'; import {useFieldInputStyling} from '../Field/fieldParts'; import { colorVars, + durationVars, + easeVars, fontWeightVars, radiusVars, sizeVars, @@ -102,7 +105,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, @@ -112,6 +115,8 @@ import { secondRows, wheelBounds, } from '../TimeInput/timeWheelRules'; +import {useMonthYearToggle} from '../DateInput/useMonthYearToggle'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {useTouchPresentation} from '../utils/touchPresentation'; import { dateInputTouchGeometry, @@ -186,6 +191,17 @@ const sizeStyles = stylex.create({ const HORIZONTAL_SEGMENT_BASIS = 196; +/** + * 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 = durationVars['--duration-medium-min']; +const TOGGLE_ENTRANCE = easeVars['--ease-entrance']; +const TOGGLE_EXIT = easeVars['--ease-exit']; +const TOGGLE_TURN = easeVars['--ease-move']; + const styles = stylex.create({ iconButton: { display: 'flex', @@ -328,11 +344,43 @@ const styles = stylex.create({ alignItems: 'center', gap: spacingVars['--spacing-0-5'], marginInlineStart: 'auto', + // 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', + '@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_ENTRANCE, + '@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', + transitionTimingFunction: { + default: 'linear', + '@media (pointer: coarse)': TOGGLE_EXIT, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, touchArrow: { minBlockSize: { @@ -360,6 +408,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 (useMonthYearToggle). A pseudo-element, so showing it + * restyles nothing inside the surfaces. + */ + touchDateSurfaceStackFading: { + '::after': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: 3, + }, }, touchDateSurface: { gridArea: '1 / 1', @@ -368,11 +431,97 @@ const styles = stylex.create({ minInlineSize: 0, flexDirection: 'column', gap: spacingVars['--spacing-2'], + // 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', + '@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_ENTRANCE, + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': + reducedMotionFadeConsts.easing, + }, }, touchDateSurfaceHidden: { 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 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. + // 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(-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(-90deg)', + }, + }, + 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', + }, + transitionTimingFunction: { + default: 'ease', + '@media (pointer: coarse)': TOGGLE_TURN, + }, + }, + touchTitleChevronOpen: { + transform: { + default: 'none', + '@media (pointer: coarse)': { + default: 'rotate(0deg)', + ':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)', + }, + }, }, touchWeekdays: { display: 'grid', @@ -567,7 +716,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 (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, 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; const [draftTime, setDraftTime] = useState<ISOTimeString | undefined>(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1027,8 +1194,9 @@ export function TouchDateTimeField({ )}> <div {...stylex.props(styles.touchHeader)}> <button + ref={titleRef} type="button" - onClick={() => setIsWheelOpen(open => !open)} + onClick={() => toggle(!isWheelOpen)} aria-expanded={isWheelOpen} aria-label={t('@astryx.dateInput.chooseMonthYear', { monthYear: monthYearLabel, @@ -1040,10 +1208,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,14 +1272,23 @@ 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, + // 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}) => ( @@ -1108,7 +1297,7 @@ export function TouchDateTimeField({ </div> ))} </div> - <MonthScroller + <MonthGrid key={`${minMonthIndex}:${maxMonthIndex}`} handleRef={scrollerHandleRef} monthModel={monthModel} @@ -1145,11 +1334,13 @@ 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, + styles.touchWheelsSurface, !isWheelOpen && styles.touchDateSurfaceHidden, )}> <div @@ -1173,7 +1364,7 @@ export function TouchDateTimeField({ size="md" width="100%" label={t('@astryx.dateInput.doneChoosingMonth')} - onClick={() => setIsWheelOpen(false)} + onClick={() => toggle(false)} /> </div> </div> diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index ac9be9497862a..104638b66ffc8 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -277,7 +277,13 @@ 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, and the day clamped to the month. +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 +travel has. ## Context menu (UIContextMenuInteraction) diff --git a/packages/core/src/motion/uikit/datePicker.ts b/packages/core/src/motion/uikit/datePicker.ts index 2e22079f20621..8ce492d1abfdc 100644 --- a/packages/core/src/motion/uikit/datePicker.ts +++ b/packages/core/src/motion/uikit/datePicker.ts @@ -3,9 +3,10 @@ /** * @file datePicker.ts * @input Nothing; data only - * @output Exports `datePicker`: the rules UIDatePicker adds to its 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. + * @output Exports `datePicker`: the rules UIDatePicker adds to its calendar + * and wheels. Its calendar pages, selects and toggles to the wheels. + * 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. @@ -16,6 +17,100 @@ 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 + * nearest the content offset. It changes as the content crosses half a + * page, during the drag and a settle, not at the lift. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * a slow drag released at 70 % of a page: the title turned to the + * next month at 51 % (offset 1124 of a 320 pt page from 960), 560 ms + * before the lift, Oct 23 2026 + */ + titleFollows: 'nearestPage', + /** + * A release picks its month by the paging rule and settles on the + * paging integrator (`paging.release`, `paging.integrator`). Slow drags + * released at 30 % and 50 % of a page settle back, and at 70 % on the + * next month. A drag toward a month past `maximumDate` stays. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * the grid's `UICollectionView` (`isPagingEnabled`, + * `decelerationRate` 0.998) read and its offsets logged per frame + * for slow drags released at 30, 50 and 70 % and a drag past the + * last allowed month, Oct 23 2026 + */ + release: 'paging', + }, + chevron: { + /** + * A tap on the previous or next chevron names the new month in the + * title at the lift. In the same frame the grid starts one page on + * `paging.animatedScroll`, a 0.3 s sinusoid. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * a tap on Next: the title changed in the first frame after the + * lift and the offset ran 960 → 1280 on the 0.3 s sinusoid from that + * frame (9.67, 21.33, 80, 160, 240, 298.67, 317.67 pt at 33, 50, 100, + * 150, 200, 250, 284 ms), Oct 23 2026 + */ + step: 'animatedScroll', + /** + * A second tap while the grid still scrolls names the month after that + * at once. A new 0.3 s sinusoid starts toward it from where the grid + * is, from rest. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline`, + * two taps on Next 150 ms apart: the title jumped two months, the + * offset held one frame at 1092.33 and then ran to 1600 on a fresh + * 0.3 s sinusoid (4, 59.33, 253.67, 448.33, 503.67 pt at 17, 67, 150, + * 233, 283 ms), Oct 23 2026 + */ + retarget: 'fromPresentation', + /** + * In the first or last month the bounds allow, the chevron toward the + * outside is disabled, not hidden. It stays in place, a touch on it + * changes nothing, and its glyph takes the tertiary label colour. + * @source measured: UIKit lab, iOS 26.2 simulator, `.date` `.inline` + * at `maximumDate`'s month: the Next button in place with opacity 1 + * and `isEnabled` false, a tap on it landing on + * `_UICalendarHeaderView` and changing nothing, its glyph tinted + * rgba(0.235, 0.235, 0.263, 0.298) (#C5C5C7 on white) while the + * button's tintColor stays the accent, Oct 23 2026 and Oct 24 2026 + */ + atBound: 'disabled', + }, monthYearWheels: { /** * Turning the month or year wheel changes the picker's date, keeping