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/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/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..f11d3d6849c7a 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`"). @@ -254,8 +259,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 +295,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 @@ -412,9 +413,9 @@ clear-glyph target of its own. | FR12, AR4 | `DateInput.test.tsx` disabled, loading, and disabledMessage cases (including "does not call a caller onClick while disabled (AST-085 FR2)" and "links the reason tooltip from the calendar button too (AST-085 FR5)"); `DateInputTouch.test.tsx` open-sheet disablement and disabled cases; `NativeDateField.test.tsx` "keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)", "does not call a caller onClick while disabled (AST-085 FR2)"; `DateInput.test.tsx` disabled theme-state cases | Disabled with and without a reason; loading; open picker becoming disabled | Native `disabled`, a change or open while disabled, a caller `onClick` while disabled, a reason not linked from each focusable control, or a missing `aria-busy` fails. Evidence gap: no test asserts that caller key, paste, or pointer handlers are skipped while disabled (the FR12 gap) or the busy-only paint. | `audit:DateInput/accessibility` | | FR12 | `DateInput.test.tsx` "typed text while the field is disabled or busy" cases and "keeps a cleared draft without committing it when the field becomes disabled (AST-085 FR2)" | `isLoading`, `isDisabled`, and a `changeAction` save that takes or fails; unfinished, valid, reported, and cleared text; shown rejection; new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateInput/behavior` | | AR1, AR3, AR5 | `DateInput.test.tsx` label, required, description, status, and invalid-input cases ("announces an alert message when typed input is invalid", "resolves the invalid-date announcement from the i18n catalog"); `NativeDateField.test.tsx` "keeps the overlay out of the accessibility tree", "keeps the field labelled, required-marked, and ref-forwarded"; `DateInputTouch.test.tsx` "associates the label natively, so the field is named without ARIA" | Every surface | A missing label, required, description, status, or invalid announcement, or an exposed native overlay, fails. | `audit:DateInput/accessibility` | -| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable") | Touch calendar and wheels; RTL | A second Tab stop per month, a wrong arrow move, reversed RTL arrows, an unbounded month window, or more than one reachable footer action fails. Evidence gap: no test asserts Home, End, PageUp, PageDown, Shift+PageUp, Shift+PageDown (the AR8 gap), or Enter and Space on a day. | `audit:DateInput/accessibility` | +| AR6, AR8, PR1 | `DateInputTouch.test.tsx` calendar-surface cases ("mounts a window of months around the visible one, not the whole century", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "moves keyboard focus by date, across the month boundary", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves a week at a time with the vertical arrows", "labels the columns with three-letter weekday names", "marks the selection and today", "pages the keyboard focus by month with PageUp/PageDown and by year with Shift, on every pointer", "under a finger: pages the keyboard focus by month with PageUp/PageDown and by year with Shift", "under a finger: only the month the pager shows takes a tab stop", "under a finger: gives a spilled date no tab stop, and the off-screen month none at all") and wheel cases ("is a single tab stop driven by the arrow keys", "shows exactly one footer action, and only the visible one is reachable"); `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` | @@ -441,11 +442,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/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..67e4f598b32e7 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -61,6 +61,15 @@ import {WHEEL_ROW_HEIGHT} from './wheelOptions'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; +import {useCalendarDays} from '../Calendar/hooks/useCalendarDays'; +import type * as CalendarDaysModule from '../Calendar/hooks/useCalendarDays'; + +// A pass-through spy: each call is one month pane rendering, which the +// month/year toggle test counts. It wraps the module `MonthPane` imports. +vi.mock('../Calendar/hooks/useCalendarDays', async importOriginal => { + const actual = await importOriginal(); + return {...actual, useCalendarDays: vi.fn(actual.useCalendarDays)}; +}); // --------------------------------------------------------------------------- // jsdom scaffolding @@ -136,8 +145,9 @@ function stubMedia({ /** * A touchscreen laptop with the sheet pinned (`presentation="bottom-sheet"`). - * Its primary pointer is fine, so the sheet's wheels are the scrolling - * columns. A finger can still turn them. + * Its primary pointer is fine, so the sheet's wheels are the scrolling columns + * and the calendar is the native scroller. A finger can still turn and swipe + * them. Under a primary finger the calendar is MonthPager instead. */ function stubTouchscreenLaptop(): void { stubMedia({pointer: 'fine', anyPointer: 'coarse', width: 1280}); @@ -181,6 +191,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 +978,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 +1084,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 +1183,19 @@ describe('DateInput — calendar surface', () => { }); it('moves focus to the other arrow when a swipe reaches the end of the range while an arrow holds focus', async () => { + // This swipe scrolls the native scroller, which a fine primary pointer + // gets. Under a primary finger the calendar is MonthPager. It reports a + // settled month to the same callback. + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); await withLayout(async () => { render( - , + , ); fireEvent.click(field()); const previous = screen.getByRole('button', {name: 'Previous month'}); @@ -1268,21 +1390,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 +1606,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 +1834,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 +1911,16 @@ describe('DateInput — month/year wheels', () => { * which is what the re-snap amounts to. */ it('holds the month when the hidden calendar is re-snapped on reveal', async () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); await withLayout(async () => { - render(); + render( + , + ); fireEvent.click(field()); openWheels(); fireEvent.click( @@ -2336,8 +2511,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 +2537,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 +2728,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 +3219,38 @@ 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: 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('), 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..f889ed2ac90f6 100644 --- a/packages/core/src/DateInput/MonthScroller.tsx +++ b/packages/core/src/DateInput/MonthScroller.tsx @@ -6,8 +6,10 @@ * @file MonthScroller.tsx * @input A calendar's month model, month bounds, selected value, constraints, * week start - * @output Exports MonthScroller and MonthScrollerHandle - * @position Internal component; consumed by TouchDateField.tsx + * @output Exports MonthScroller and MonthScrollerHandle, and the month grid + * MonthPager shares: MonthPane, monthGridKeyTarget and useMonthGridFocus + * @position Internal component; consumed by TouchDateField.tsx. MonthPager.tsx + * renders its MonthPane and uses its useMonthGridFocus. * * The continuous surface: months stacked vertically in one scroller, each pane * exactly as tall as the scrollport and snapped to its start. Two consequences @@ -32,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, @@ -198,12 +209,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 +240,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 +266,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 +398,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 +740,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 +788,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 +803,24 @@ 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; } /** * 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,6 +833,8 @@ function MonthPane({ onSelect, onDayKeyDown, onDayFocus, + hasTabStop, + paneStyle, }: MonthPaneProps) { const locale = useLocale(); const {calendar} = monthModel; @@ -928,9 +853,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 +873,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 && @@ -978,7 +902,7 @@ function MonthPane({ type="button" data-date={day.iso} data-outside={day.isOutside ? '' : undefined} - tabIndex={day.iso === tabbableISO ? 0 : -1} + tabIndex={hasTabStop && day.iso === tabbableISO ? 0 : -1} aria-label={formatCalendarDate( calendar, day.date, @@ -997,10 +921,9 @@ function MonthPane({ {...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, )}> @@ -1021,6 +944,164 @@ function MonthPane({ ))}
); -} +}); 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..e7e066f72b542 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -125,8 +125,9 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {normalizeDayOfWeek} from '../utils/dateTypes'; import {getWeekdayHeaders} from '../Calendar/getWeekdayHeaderNames'; -import {useTouchPresentation} from '../utils/touchPresentation'; +import {MonthPager} from './datePickerTouch'; import {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; +import {useTouchPresentation} from '../utils/touchPresentation'; import {MonthYearWheels} from './MonthYearWheels'; import { DEFAULT_MONTH_REACH, @@ -687,6 +688,8 @@ export function TouchDateField({ isSheetOpen ? sheetBodyRef : inputRef, ); const [isWheelOpen, setIsWheelOpen] = useState(false); + const {isTouch} = useTouchPresentation(); + const MonthGrid = isTouch ? MonthPager : MonthScroller; const scrollerHandleRef = useRef(null); const previousArrowRef = useRef(null); const nextArrowRef = useRef(null); @@ -979,7 +982,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); @@ -1227,7 +1229,7 @@ export function TouchDateField({ styles.panelBeneath, isWheelOpen && styles.panelBeneathHidden, )}> - { + 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/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/useOwnScrollGesture.test.tsx b/packages/core/src/DateInput/useOwnScrollGesture.test.tsx new file mode 100644 index 0000000000000..797da9a59e133 --- /dev/null +++ b/packages/core/src/DateInput/useOwnScrollGesture.test.tsx @@ -0,0 +1,125 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useOwnScrollGesture.test.tsx + * @input useOwnScrollGesture's `'pager'` mode on an element under the body + * @output Which touch and pointer moves reach the element's ancestors + * @position Unit tests beside useOwnScrollGesture.ts; the sheet cases sit in + * DateInputTouch.test.tsx + */ + +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, describe, expect, it} from 'vitest'; + +import {useOwnScrollGesture} from './useOwnScrollGesture'; + +function Pager() { + const ref = useRef(null); + useOwnScrollGesture(ref, 'pager'); + return
; +} + +/** The event types that reach the body, in order. */ +function watchBody(): {seen: string[]; stop: () => void} { + const seen: string[] = []; + const types = ['touchstart', 'touchmove', 'pointerdown', 'pointermove']; + const listener = (event: Event) => seen.push(event.type); + for (const type of types) { + document.body.addEventListener(type, listener); + } + return { + seen, + stop: () => { + for (const type of types) { + document.body.removeEventListener(type, listener); + } + }, + }; +} + +/** jsdom has no constructible `TouchEvent`; this one carries its lists. */ +function touch(el: Element, type: string, at: {x: number; y: number}) { + const event = new Event(type, {bubbles: true}); + const point = {identifier: 1, clientX: at.x, clientY: at.y, target: el}; + const touches = type === 'touchend' ? [] : [point]; + Object.defineProperties(event, { + changedTouches: {value: [point]}, + touches: {value: touches}, + targetTouches: {value: touches}, + }); + el.dispatchEvent(event); +} + +const ORIGIN = {x: 150, y: 200}; + +/** A touch from `ORIGIN` through `moves` (offsets from it), then the lift. */ +function dragThrough( + el: Element, + moves: ReadonlyArray, +) { + touch(el, 'touchstart', ORIGIN); + for (const [dx, dy] of moves) { + touch(el, 'touchmove', {x: ORIGIN.x + dx, y: ORIGIN.y + dy}); + } + touch(el, 'touchend', ORIGIN); +} + +describe("useOwnScrollGesture's 'pager'", () => { + afterEach(cleanup); + + it('holds a touch’s moves from the sheet until it has travelled 10 px either way', () => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + touch(pager, 'touchstart', ORIGIN); + touch(pager, 'touchmove', {x: ORIGIN.x - 5, y: ORIGIN.y + 6}); + touch(pager, 'touchmove', {x: ORIGIN.x - 7, y: ORIGIN.y + 8.5}); + expect(body.seen).toEqual(['touchstart']); + // The vertical travel reaches 10 px first: the sheet's from this move. + touch(pager, 'touchmove', {x: ORIGIN.x - 8, y: ORIGIN.y + 10.3}); + expect(body.seen).toEqual(['touchstart', 'touchmove']); + body.stop(); + }); + + /** UIKit's own first samples past the threshold, in a sheet. */ + it.each([ + [10, 11, 'pager'], + [10.7, 12.7, 'pager'], + [9.7, 11.3, 'sheet'], + [8, 10.3, 'sheet'], + ] as const)( + 'a first move past 10 px at (%f, %f) makes the drag the %s’s', + (dx, dy, owner) => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + dragThrough(pager, [ + [-dx, dy], + [-dx * 2, dy * 2], + ]); + expect(body.seen.includes('touchmove')).toBe(owner === 'sheet'); + body.stop(); + }, + ); + + it('decides a touch pointer’s moves the same way and passes a mouse’s', () => { + render(); + const pager = screen.getByTestId('pager'); + const body = watchBody(); + const at = (dx: number, dy: number, pointerType: string) => ({ + pointerId: 1, + pointerType, + isPrimary: true, + clientX: ORIGIN.x + dx, + clientY: ORIGIN.y + dy, + }); + fireEvent.pointerDown(pager, at(0, 0, 'touch')); + fireEvent.pointerMove(pager, at(-5, 6, 'touch')); + fireEvent.pointerMove(pager, at(-12, 4, 'touch')); + expect(body.seen).toEqual(['pointerdown']); + fireEvent.pointerMove(pager, at(-20, 4, 'mouse')); + expect(body.seen).toEqual(['pointerdown', 'pointermove']); + body.stop(); + }); +}); diff --git a/packages/core/src/DateInput/useOwnScrollGesture.ts b/packages/core/src/DateInput/useOwnScrollGesture.ts index 536423f67be09..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/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index e3200bd140559..3b9b6bebb98b9 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -102,7 +102,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, @@ -568,6 +568,7 @@ export function TouchDateTimeField({ const [activePanel, setActivePanel] = useState('date'); const [isWheelOpen, setIsWheelOpen] = useState(false); const {isTouch} = useTouchPresentation(); + const MonthGrid = isTouch ? MonthPager : MonthScroller; const [draftTime, setDraftTime] = useState(() => normalizeISOTime(splitDateTime(value).time, hasSeconds), ); @@ -1108,7 +1109,7 @@ export function TouchDateTimeField({
))} -