From ae29720d40be3aede863ad6d5038201b62fbc592 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 00:58:34 -0800 Subject: [PATCH 1/3] fix(DateInput, DateTimeInput): under a finger the month/year wheels move the date like UIDatePicker's Under a finger, turning the month or year wheel behind the sheet calendar's title moves a chosen date to the wheels' month and keeps its day: October 15 becomes May 15, and a day the month lacks becomes its last day (January 31 becomes February 28), inside min and max, by the months of the calendar the sheet shows; DateTimeInput keeps the time. A date dateConstraints refuses is not chosen, and with no date chosen the wheels move only the calendar. With a mouse the wheels move the calendar only, as today. The month wheel does not loop. DateInput FR22 records the rule under a coarse primary pointer, and DateTimeInput FR14 does the same and keeps the time. Each has a verification row. --- .changeset/month-year-wheels-move-the-date.md | 12 + .../components/DateInput/DateInput.spec.md | 22 +- .../DateTimeInput/DateTimeInput.spec.md | 2 + .../DateInputTouch.calendar.test.tsx | 38 +++- .../src/DateInput/DateInputTouch.test.tsx | 205 ++++++++++++++++++ .../core/src/DateInput/TouchDateField.tsx | 33 ++- ...onthYearWheels.interaction.browser.spec.ts | 123 +++++++++++ packages/core/src/DateInput/monthGeometry.ts | 36 +++ .../DateTimeInput/DateTimeInputTouch.test.tsx | 129 ++++++++++- .../src/DateTimeInput/TouchDateTimeField.tsx | 42 +++- 10 files changed, 621 insertions(+), 21 deletions(-) create mode 100644 .changeset/month-year-wheels-move-the-date.md create mode 100644 packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts diff --git a/.changeset/month-year-wheels-move-the-date.md b/.changeset/month-year-wheels-move-the-date.md new file mode 100644 index 0000000000000..91bde7aa8b4d8 --- /dev/null +++ b/.changeset/month-year-wheels-move-the-date.md @@ -0,0 +1,12 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under a finger, the month and year wheels behind the calendar title in the DateInput and DateTimeInput bottom sheets now behave like the iOS date picker's: + +- Turning a wheel moves a chosen date to the new month and keeps its day, so October 15 becomes May 15. A day the month lacks becomes its last day, the result stays within `min` and `max`, and DateTimeInput keeps the time. A date that `dateConstraints` refuses is not chosen; the calendar still moves. +- With no date chosen, the wheels only move the calendar. + +With a mouse, the wheels still move only the calendar. + +@vjeux diff --git a/docs/specs/components/DateInput/DateInput.spec.md b/docs/specs/components/DateInput/DateInput.spec.md index efc66de1274ac..32de3d853109e 100644 --- a/docs/specs/components/DateInput/DateInput.spec.md +++ b/docs/specs/components/DateInput/DateInput.spec.md @@ -121,7 +121,7 @@ Consumer migration instructions belong in consumer docs and release notes. - The shared bottom-sheet picker wheel: how a wheel rests, commits, steps, turns, and looks wherever DateInput, TimeInput, or DateTimeInput renders it in a sheet (FR13–FR15, FR17, FR18, DEC-1, DEC-2), and DateInput's own month and year - wheels. + wheels (FR22). - 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`"). @@ -185,6 +185,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR19 | The bottom-sheet calendar's weekday row MUST show the weekday names `component:Calendar` DD1 sets for the provider locale, in the order of the panes' day columns (`weekStartsOn`), so both pickers label a locale's columns alike. | | FR20 | The pointer field reads and writes typed dates in the resolved calendar (`spec:AST-111` FR2), and its Calendar shows that calendar. It reads the dates `Intl.DateTimeFormat` writes for the locale and calendar, in any Unicode decimal digits and with month and era names of the locale and English; in the Gregorian calendar, text that matches none of these forms, or that these forms read as a date the calendar does not have, still commits on Enter or blur when `parseDateInput` reads it, so `13/5/2026` in `en-US` commits as 13 May 2026 (`spec:AST-111` FR9). A typed year is a year of the calendar (`spec:AST-111` FR10). It rejects a date the calendar does not have and never adjusts it; a typed date outside `min`, `max`, or `dateConstraints` shows and announces an out-of-range error that writes the bounds in the calendar, and Enter or blur keeps both kinds of rejected text so the person can correct it, while unreadable text reverts (`spec:AST-111` FR11). While a person types, `onChange` fires only for complete input, at most once for each distinct value in one typing session, and a one-digit day stays open until another digit, Enter, or blur, so a day the month lacks is never reported as another date; a two-digit year and input without a year commit on Enter or blur, in every calendar system, and a year of one or three digits is not a date: like other text the field cannot finish, Enter or blur keeps it and shows the unreadable message as the field status (`spec:AST-111` FR12, AR3). Each rejection is announced once: a shown status announces itself, and the field's alert carries only a rejection that no status shows. `date`, `date_long`, and `date_weekday` write the committed text in the calendar and read back as the same date; `system_date` stays Gregorian ISO and the field reads ISO input with it (`spec:AST-111` FR13). Text from a custom `format` function that the field cannot read back cannot be re-committed after an edit (`spec:AST-111` FR14). Values stay Gregorian ISO dates (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR9–FR14; current source; parser, round-trip, and calendar typed-entry tests | | FR21 | With a calendar other than `gregory` or `iso8601`, every presentation that would show the native control — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the popover on a fine pointer and the bottom sheet on a coarse pointer, because the native control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet shows months of the calendar: each scroller pane lays out one calendar month with calendar labels and day names and the locale's digits, as `component:Calendar` shows it, and every pane keeps the six-row height, including the five- or six-day thirteenth month of the Coptic and Ethiopic calendars. The month wheel lists the months of the selected calendar year, leap months by name; a year change keeps the month when the new year has it and otherwise moves to its common counterpart (Adar I becomes Adar); year labels carry the era where the calendar has one, such as `令和8`, and a year whose first and last days fall in different eras keeps one row, labeled with the era year of the date the wheels show: that year, the selected month, and the day the picker keeps, clamped to the month, else the month's first day. So 2019 reads `平成31` with January selected and `令和元` with July, and the row's text and accessible name change as the month wheel turns, while its size stays the same. The month and year heading takes its era from the same date when the shown month spans an era change, so January 1989 reads `昭和64年1月` for a kept day of 3; every other heading names the era of the month's middle day. A pane's grid name always takes the era of its month's middle day, so that pane reads `平成元年1月`. The closed sheet field writes the committed text in the calendar (`spec:AST-111` FR16). Values stay Gregorian ISO dates (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation, touch calendar, and no-`Temporal` tests | +| FR22 | Under a coarse primary pointer, turning the bottom-sheet calendar's month or year wheel MUST move a chosen date to the wheels' month, keeping its day: a day that month lacks becomes the month's last day (January 31 becomes February 28), and the moved date MUST stay within `min` and `max`. A date that `dateConstraints` refuses is not chosen, and with no date chosen the wheels move only the calendar. A year that takes the shown month out of the range turns the month wheel onto the nearest reachable month by FR14. Opening the wheels changes nothing, and under a fine pointer the wheels move only the calendar. | UIDatePicker on the iOS 26.2 simulator; current source | ### Allowed variation @@ -301,15 +302,15 @@ Consumer migration instructions belong in consumer docs and release notes. ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| -------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | ------------------- | -| Label and supporting text | Shared Field hierarchy surrounds one coherent input boundary. | `family:input-fields` and `component:Field` | Supporting | FR10, AR1 | -| Field surface | One stable bordered input surface contains value and end controls. | `family:input-fields/FR1–FR2` | Prominent | FR1, FR7, FR10 | -| Calendar affordance | Communicates the picker path without replacing typed entry. | Current source and consumer docs | Supporting | FR8 | -| Calendar popover | Presents date selection while preserving Field ownership. | `component:Calendar`, `component:Popover` | Prominent | FR5, FR8–FR9 | -| Bottom-sheet calendar | Presents the same date contract for a coarse pointer. | Current source and `spec:AST-043`; weekday names: `component:Calendar` DD1 | Prominent | FR2, FR4, FR9, FR19 | -| Native picker | Hands picker chrome to the browser/OS while retaining DateInput field semantics. | `spec:AST-043`, browser/OS | Prominent | FR2–FR6, AR5 | -| Clear, busy, and status controls | Use the shared input-family end-control and status relationships. | `family:input-fields` and shared primitives | Supporting | FR7, FR11–FR12 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| -------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | ------------------------- | +| Label and supporting text | Shared Field hierarchy surrounds one coherent input boundary. | `family:input-fields` and `component:Field` | Supporting | FR10, AR1 | +| Field surface | One stable bordered input surface contains value and end controls. | `family:input-fields/FR1–FR2` | Prominent | FR1, FR7, FR10 | +| Calendar affordance | Communicates the picker path without replacing typed entry. | Current source and consumer docs | Supporting | FR8 | +| Calendar popover | Presents date selection while preserving Field ownership. | `component:Calendar`, `component:Popover` | Prominent | FR5, FR8–FR9 | +| Bottom-sheet calendar | Presents the same date contract for a coarse pointer. | Current source and `spec:AST-043`; weekday names: `component:Calendar` DD1 | Prominent | FR2, FR4, FR9, FR19, FR22 | +| Native picker | Hands picker chrome to the browser/OS while retaining DateInput field semantics. | `spec:AST-043`, browser/OS | Prominent | FR2–FR6, AR5 | +| Clear, busy, and status controls | Use the shared input-family end-control and status relationships. | `family:input-fields` and shared primitives | Supporting | FR7, FR11–FR12 | ### Theming anatomy @@ -417,6 +418,7 @@ clear-glyph target of its own. | FR19 | `DateInputTouch.test.tsx` "labels the columns with three-letter weekday names", "labels the columns in %s with the same names as the Calendar header" for `fr-FR`, `pt-PT`, `pl-PL`, and `id-ID`, "rotates the weekday header with weekStartsOn" | English; a locale within Calendar DD1's four-character limit; locales above it; a locale with no shipped weekday data; `weekStartsOn` rotation | A weekday row that differs from the Calendar header names for the same locale, or that does not rotate with `weekStartsOn`, fails. | — | | FR20, AR3, AR9 | `calendarDateParser.test.ts` reading, year, rejection-kind, completeness, committed-text, and calendar-name cases; `calendarDateParser.roundtrip.test.ts` committed text and format hint of every calendar in its native locale, English, and Swedish, sampled from 1900 through 2100; `DateInput.calendar.test.tsx` ("reports one Persian date typed in Persian digits, only when complete", "reads a plain Adar in a Hebrew leap year as Adar II", "reads Japanese era dates and rejects a year outside its era", "rejects a date the calendar does not have and keeps it on blur", "shows an out-of-range error with the bounds in the calendar", "announces a different message for each kind of rejection", "fires once for a day-first date typed one key at a time", "commits Gregorian text that only parseDateInput reads, on blur only", "commits a day-first date that does not exist month-first, on blur only", "commits a month-first date that does not exist day-first, on blur", "never falls back to a month-first reading in a day-first locale", "keeps a one-digit year on %s and announces it as invalid", "reports no date while %s %s text grows to a day the month lacks", "reports a retyped date again after the caller keeps its value", "still rejects a Gregorian date that does not exist in either order", "never reads a date that does not exist in another calendar with parseDateInput", "shows a format hint in the empty focused field", "shows a caller placeholder instead of the format hint", "uses max when max is before today", "uses min when min is after today", "uses the nearest weekday when today is a disabled Saturday", "names the calendar in the description", "names no calendar when the field uses the provider calendar", "names the Gregorian calendar inside a Persian provider", "names no calendar for an iso8601 field in a Gregorian provider", "names the Persian calendar without a provider calendar"); real Chromium and Firefox typing with native `Temporal`, a polyfill, and no `Temporal` | Persian digits, Hebrew leap year, Japanese era change, Buddhist bounds, partial input, Gregorian day-first typing, a one-digit Gregorian year, no `Temporal` | A typed date reported before it is complete, an adjusted date, a failed round trip, Gregorian text that FR20 says commits failing to commit on blur, a one-digit year that commits or reverts silently, a rejection announced twice, a shared rejection message, a missing hint, a hint that replaces a caller placeholder, a hint example the field rejects, a calendar description that is missing or that repeats the provider's calendar, or a non-ISO value fails. Evidence gap: no WebKit run; no server render and hydrate test. | `audit:DateInput/behavior` | | FR21 | `DateInput.calendarPresentation.test.tsx` (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"); `DateInputTouch.calendar.test.tsx` ("lays out a Persian month with its days, labels and locale digits", "shows Adar I and Adar II as separate months in a Hebrew leap year", "keeps six rows for the Ethiopic thirteenth month", "lists 13 months in a Hebrew leap year and 12 in a common year", "labels Japanese years with their era", and the month-model round trips); `calendarSystem.test.ts` `formatCalendarYearLabel` and `calendarYearSpansEras` cases (2019 in January and May, 1989 on January 3 and 22, 1926, 1912, and 2020); `DateInputTouch.calendar.test.tsx` "names the era of the shown month for a year with an era change" and the split-month heading cases (January 3, 1989; December 26, 1926); `calendarSystem.test.ts` `formatCalendarMonthLabel` kept-day cases; `DateInput.noTemporal.test.tsx`; real Chromium, Firefox, and WebKit bottom sheets and popovers with native `Temporal`, a polyfill, and no `Temporal` | Persian, Hebrew leap and common years, Ethiopic thirteenth month, Japanese eras, no `Temporal` | A native date control for a non-Gregorian calendar, a wheel with 12 months for a 13-month year, a pane of another height, a lost leap month, a missing era, a year with an era change labeled with an era other than that of the shown date, or a non-ISO value fails | `audit:DateInput/behavior` | +| FR22 | `DateInputTouch.test.tsx` "under a finger: moves the chosen date with the wheels, keeping its day", "under a finger: clamps a day the new month lacks to its last day", "under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too", "under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves", "under a finger: keeps a date a custom constraint refuses, while the calendar moves", "only moves the calendar when no date is chosen", "with a mouse the scrolling wheels move only the calendar, not the date", "under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)", "moves a date to another month keeping its day, within the month and the bounds"; `MonthYearWheels.interaction.browser.spec.ts` "a turn of the month wheel moves the chosen date, keeping its day", "the scrolling month wheel moves only the calendar, not the date"; `DateInputTouch.calendar.test.tsx` "moves a date by calendar months, keeping its day of the month" | A month turn; a day the new month lacks; a year past the range; no date; a refused date; opened outside the range; a mouse | A wheel that leaves the chosen date behind, keeps a day the month lacks, leaves the range, chooses a refused date, changes the value on opening, or moves the date under a mouse fails. | `audit:DateInput/behavior` | | Theming anatomy | `DateInput.test.tsx` clear-icon and calendar-toggle icon theme-target cases; `themingTargets.test.ts`; `scripts/check-knowledge.mjs` | Toggle, clear glyph, disabled root | A missing or moved target, or a stale anatomy mapping, fails. | `audit:DateInput/theming` | ## Current-state impact diff --git a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md index 9b6f5175bbfa3..86c8bb102d664 100644 --- a/docs/specs/components/DateTimeInput/DateTimeInput.spec.md +++ b/docs/specs/components/DateTimeInput/DateTimeInput.spec.md @@ -142,6 +142,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR11 | The pointer date segment follows `component:DateInput` FR20 for typed entry in the resolved calendar: it reads the dates `Intl.DateTimeFormat` writes for the locale and calendar, and in the Gregorian calendar, on Enter or blur, text it cannot otherwise read and a date the locale's field order lacks, such as `13/5/2026` in `en-US`, when `parseDateInput` reads it, with a one- or three-digit year not a date, reads years of the calendar, rejects a date the calendar does not have without adjusting it, shows and announces an out-of-range error with the bounds in the calendar, fires `onChange` while typing only for complete input and at most once for each distinct date in one typing session, shows and announces each rejection once as DateInput does, and writes the committed date as `date_long` in the calendar (`spec:AST-111` FR9–FR13). Its Calendar shows the same calendar, and the combined value stays a Gregorian ISO date-time (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR9–FR13; current source; calendar typed-entry tests | | FR12 | With a calendar other than `gregory` or `iso8601`, every presentation that would show native controls — `adaptive-native`, the default, deprecated `touch` and `always`, and an explicit `native` — shows the Astryx popovers on a fine pointer and the bottom sheet on a coarse pointer, for the date and the time alike, because the native date control is Gregorian (`spec:AST-111` FR15). An explicit `native` also warns once in development. The bottom sheet's Date panel follows `component:DateInput` FR21: calendar months in six-row panes, the month wheel of the selected calendar year with leap months, the common counterpart on a year change, and year labels with their era; its date segment writes the date as `date_long` in the calendar (`spec:AST-111` FR16). The combined value stays a Gregorian ISO date-time (`spec:AST-111` FR3). | `spec:AST-111` FR3, FR15, FR16; current source; calendar presentation and touch calendar tests | | FR13 | While disabled or busy, the date segment keeps or drops typed text as `component:DateInput` FR12 states, judging it on the next Enter or blur once editable. | `spec:AST-085` FR2; current source and tests | +| FR14 | Under a coarse primary pointer, the date sheet's month and year wheels MUST move a chosen date as `component:DateInput` FR22 does, keeping its time. | `component:DateInput` FR22; UIDatePicker on the iOS 26.2 simulator | ### Allowed variation @@ -312,6 +313,7 @@ leading clock glyph uses `date-time-input-clock-icon`. | FR11, AR5 | `DateTimeInput.calendar.test.tsx` ("reports one Persian date typed in Persian digits, only when complete", "writes the committed date in the calendar", "rejects a date the calendar does not have and names the calendar", "shows an out-of-range error with the bounds in the calendar", "names the calendar and shows the hint when focused and empty", "shows a caller placeholder instead of the format hint", "names no calendar when the field uses the provider calendar", "names the Gregorian calendar inside a Persian provider", "commits Gregorian text that only parseDateInput reads on blur", "commits a day-first date that does not exist month-first, on blur only", "fires once for a day-first Gregorian date typed one key at a time"); the parser and round-trip suites under `component:DateInput` FR20 | Persian digits, Hebrew leap year, Japanese era bound, Gregorian day-first typing | A typed date reported before it is complete, an adjusted date, a shared rejection message, a hint that replaces a caller placeholder, a calendar description that is missing or that repeats the provider's calendar, or a non-ISO value fails. Evidence gap: no real-browser run drives the DateTimeInput date segment itself. | `audit:DateTimeInput/behavior` | | FR12 | `DateInput.calendarPresentation.test.tsx` DateTimeInput cases (every presentation and deprecated `nativePicker` value in Gregorian and Persian on fine and coarse pointers; "warns once when an explicit native cannot show the calendar"; "writes the sheet date segment in %s %s with its era" for `en` and `fa-IR` Persian and `th-TH` Buddhist); `DateInputTouch.calendar.test.tsx` "shows Persian months and the date in the calendar"; `DateInput.noTemporal.test.tsx` "shows a Persian DateTimeInput as Gregorian native controls" | Persian on fine and coarse pointers, explicit `native`, no `Temporal` | A native date or time control for a non-Gregorian calendar, a missing warning for an explicit `native`, or a Date panel in another calendar, fails | `audit:DateTimeInput/surfaces` | | FR13 | `DateTimeInput.test.tsx` "typed text while the field is disabled or busy" cases | Loading, disabled, and saves; unfinished, valid, reported, rejected, new `value` | A commit, `onChange`, reset, or rejection while disabled or busy, a rejection that does not return once editable, typed text that outlives a new `value`, or reported text that outlives the lock fails. | `audit:DateTimeInput/state` | +| FR14 | `DateTimeInputTouch.test.tsx` "under a finger: moves the chosen date with the month wheel, keeping the day and the time", "under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)", "under a finger: opening the month/year wheels on a date outside the range changes nothing", "under a finger: keeps a date a custom constraint refuses, while the calendar moves" | A month turn with a time; a pending Action; opened out of range; a refused date | A wheel that drops the time, leaves the date behind, changes the value on opening, or chooses a refused date fails. | `audit:DateTimeInput/surfaces` | | Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` | ## Current-state impact diff --git a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx index 6b77f5f525706..ec9f50ceb4966 100644 --- a/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.calendar.test.tsx @@ -19,8 +19,12 @@ import { type ISODateTimeString, } from '../DateTimeInput/DateTimeInput'; import {InternationalizationProvider} from '../i18n/InternationalizationProvider'; -import {createMonthModel} from './monthGeometry'; -import type {CalendarSystem} from '../utils/calendarSystem'; +import {createMonthModel, dateInMonth} from './monthGeometry'; +import { + type CalendarSystem, + calendarDateFromFields, + calendarDayOfMonth, +} from '../utils/calendarSystem'; import type {ISODateString} from '../utils/dateTypes'; import {plainDateAddDays, plainDateFromISO} from '../utils/plainDate'; @@ -473,4 +477,34 @@ describe('the month model', () => { month: 6, }); }); + + it('moves a date by calendar months, keeping its day of the month', () => { + const persian = createMonthModel('persian'); + const mehr = persian.indexOfYearMonth(1405, 7); + // Shahrivar has 31 days and Mehr 30, so the 31st becomes the 30th. + for (const [day, expected] of [ + [15, 15], + [31, 30], + ] as const) { + const shahrivar = calendarDateFromFields('persian', { + year: 1405, + month: 6, + day, + })!; + const moved = dateInMonth(persian, shahrivar, mehr); + expect(persian.indexOf(moved)).toBe(mehr); + expect(calendarDayOfMonth('persian', moved)).toBe(expected); + } + // Adar I has 30 days in the leap year 5784; Adar of 5785 has 29. + const hebrew = createMonthModel('hebrew'); + const adar = hebrew.indexOfYearMonth(5785, 6); + const adarI30 = calendarDateFromFields('hebrew', { + year: 5784, + month: 6, + day: 30, + })!; + const moved = dateInMonth(hebrew, adarI30, adar); + expect(hebrew.indexOf(moved)).toBe(adar); + expect(calendarDayOfMonth('hebrew', moved)).toBe(29); + }); }); diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index 9024ebce7b0d5..fc89f2af722db 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -49,6 +49,8 @@ import { monthIndexOf, fromMonthIndex, clampIndex, + createMonthModel, + dateInMonth, rowAtScrollOffset, scrollOffsetForRow, paneWindow, @@ -132,6 +134,15 @@ 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. + */ +function stubTouchscreenLaptop(): void { + stubMedia({pointer: 'fine', anyPointer: 'coarse', width: 1280}); +} + function setViewport(kind: 'mobile' | 'desktop'): void { stubMedia( kind === 'mobile' @@ -1747,6 +1758,165 @@ describe('DateInput — month/year wheels', () => { expect(title()).toHaveTextContent('March 2025'); }); + /** + * UIDatePicker's inline calendar: turning the month or year wheel changes + * the date, keeping its day (datePicker.calendar.monthYearWheels + * .changesDate), clamped to the month and the bounds. + */ + it('with a mouse the scrolling wheels move only the calendar, not the date', () => { + stubTouchscreenLaptop(); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const onChange = vi.fn(); + renderAndOpen( + , + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(onChange).not.toHaveBeenCalled(); + expect(field()).toHaveValue('March 21, 2026'); + }); + + it('under a finger: keeps a date a custom constraint refuses, while the calendar moves', () => { + const onChange = vi.fn(); + renderAndOpen( + !(date.getMonth() === 4 && date.getDate() === 21), + ]} + onChange={onChange} + />, + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(onChange).not.toHaveBeenCalled(); + expect(field()).toHaveValue('March 21, 2026'); + expect(title()).toHaveTextContent('May 2026'); + }); + + it('under a finger: moves the chosen date with the wheels, keeping its day', () => { + const onChange = vi.fn(); + renderAndOpen( + , + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(onChange).toHaveBeenLastCalledWith('2026-05-21'); + expect(field()).toHaveValue('May 21, 2026'); + }); + + it('under a finger: a date the wheels move runs changeAction after onChange, shows at once, and is busy (family:input-fields FR6)', async () => { + const calls: string[] = []; + let resolve: () => void = () => {}; + const changeAction = vi.fn(async (value: string | undefined) => { + calls.push(`changeAction ${value}`); + return new Promise(done => { + resolve = done; + }); + }); + renderAndOpen( + calls.push(`onChange ${value}`)} + changeAction={changeAction} + />, + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + await waitFor(() => { + expect(calls).toEqual(['onChange 2026-05-21', 'changeAction 2026-05-21']); + }); + // Optimistic: the moved date shows while the Action runs, the field busy. + expect(field()).toHaveValue('May 21, 2026'); + expect(document.querySelector('[aria-busy="true"]')).not.toBeNull(); + await act(async () => { + resolve(); + }); + }); + + it('under a finger: clamps a day the new month lacks to its last day', () => { + const onChange = vi.fn(); + renderAndOpen( + , + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('April'), + ); + expect(onChange).toHaveBeenLastCalledWith('2026-04-30'); + }); + + it('under a finger: takes a year change past the range on to its nearest reachable month, date and month wheel too', async () => { + const onChange = vi.fn(); + renderAndOpen( + , + ); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Year'})).getByText('2025'), + ); + // March 2025 is before the range: June 2025, the first reachable month. + expect(title()).toHaveTextContent('June 2025'); + expect(onChange).toHaveBeenLastCalledWith('2025-06-21'); + const months = screen.getByRole('listbox', {name: 'Month'}); + await waitFor(() => + expect( + document.getElementById(months.getAttribute('aria-activedescendant')!), + ).toHaveTextContent('June'), + ); + }); + + it('under a finger: opening the wheels on a date outside the range changes nothing until a wheel moves', () => { + // An untouched value is never changed by opening: the wheels show the + // calendar's month, inside the range, and nothing commits. + const onChange = vi.fn(); + renderAndOpen( + , + ); + openWheels(); + expect(onChange).not.toHaveBeenCalled(); + expect(field()).toHaveValue('December 15, 2025'); + }); + + it('only moves the calendar when no date is chosen', () => { + const onChange = vi.fn(); + renderAndOpen(); + openWheels(); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(title()).toHaveTextContent('May 2026'); + expect(onChange).not.toHaveBeenCalled(); + }); + it('is a single tab stop driven by the arrow keys', () => { renderAndOpen(); openWheels(); @@ -2488,6 +2658,41 @@ describe('monthGeometry', () => { ); }); + it('moves a date to another month keeping its day, within the month and the bounds', () => { + const d = (year: number, month: number, day: number) => ({ + year, + month, + day, + }); + const gregory = createMonthModel('gregory'); + expect( + dateInMonth(gregory, d(2026, 10, 15), toMonthIndex(2026, 5)), + ).toEqual(d(2026, 5, 15)); + expect(dateInMonth(gregory, d(2026, 1, 31), toMonthIndex(2026, 2))).toEqual( + d(2026, 2, 28), + ); + expect(dateInMonth(gregory, d(2024, 1, 31), toMonthIndex(2024, 2))).toEqual( + d(2024, 2, 29), + ); + expect( + dateInMonth( + gregory, + d(2026, 6, 10), + toMonthIndex(2026, 5), + d(2026, 5, 20), + ), + ).toEqual(d(2026, 5, 20)); + expect( + dateInMonth( + gregory, + d(2026, 6, 25), + toMonthIndex(2026, 7), + null, + d(2026, 7, 4), + ), + ).toEqual(d(2026, 7, 4)); + }); + it('clamps to the reachable range', () => { expect(clampIndex(5, 10, 20)).toBe(10); expect(clampIndex(25, 10, 20)).toBe(20); diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 5f90e8b2277f7..d0c39f57ff978 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -106,6 +106,8 @@ import { rtlStyles, themeProps, plainDateFromISO, + plainDateIsEqual, + plainDateToISO, plainDateToday, type ISODateString, } from '../utils'; @@ -120,12 +122,14 @@ 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 {MonthScroller, type MonthScrollerHandle} from './MonthScroller'; import {MonthYearWheels} from './MonthYearWheels'; import { DEFAULT_MONTH_REACH, clampIndex, createMonthModel, + dateInMonth, } from './monthGeometry'; import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; import { @@ -899,10 +903,31 @@ export function TouchDateField({ // A wheel commit steers the scroller immediately, even though it is behind // the wheels: it keeps its layout box while hidden, so by the time the // wheels close it is already resting on the new month. - const handleWheelChange = useCallback((next: number) => { - setMonthIndex(next); - scrollerHandleRef.current?.scrollToMonth(next, 'auto'); - }, []); + // Turning the month or year wheel moves the calendar. Under a finger it + // also moves a chosen date with it, keeping its day (UIDatePicker's inline + // calendar: datePicker.calendar.monthYearWheels.changesDate), clamped to + // the month and the bounds. A date a custom constraint refuses stays. + const {isTouch} = useTouchPresentation(); + const handleWheelChange = useCallback( + (next: number) => { + setMonthIndex(next); + scrollerHandleRef.current?.scrollToMonth(next, 'auto'); + if (!isTouch || selectedDate == null) { + return; + } + const moved = dateInMonth( + monthModel, + selectedDate, + next, + min != null ? plainDateFromISO(min) : null, + max != null ? plainDateFromISO(max) : null, + ); + if (!plainDateIsEqual(moved, selectedDate) && !isDateDisabled(moved)) { + fireChange(plainDateToISO(moved)); + } + }, + [isTouch, selectedDate, monthModel, min, max, isDateDisabled, fireChange], + ); /** * The calendar reports the month it has scrolled to — but only while it is diff --git a/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts new file mode 100644 index 0000000000000..d4498ea609f42 --- /dev/null +++ b/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts @@ -0,0 +1,123 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MonthYearWheels.interaction.browser.spec.ts + * @input The built "Bottom Sheet Narrow Range" DateInput story (March 15, + * 2026 in a 2026–2027 range), real touch input and the interaction harness + * @output Interaction proofs for the month/year wheels behind the calendar + * title moving the date like UIDatePicker's: a drag of the month wheel moves + * the chosen date to the next month keeping its day. They run under a + * finger in Chromium (as on Android) and WebKit alike; with a mouse the scrolling + * wheels move only the calendar. + * @position Interaction tests of MonthYearWheels.tsx and TouchDateField.tsx + * (`*.interaction.browser.spec.ts`, run by `pnpm test:interaction`) + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + drag, + openStory, + touchDriver, + waitForRest, + withStorybook, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {WHEEL_ROW_HEIGHT} from '../wheelOptions'; + +const storybook = withStorybook(test); + +async function openWheels(page: Page) { + await openStory( + page, + storybook(), + 'core-dateinput--bottom-sheet-narrow-range', + ); + const driver = await touchDriver(page); + const tapCenter = async (locator: Locator) => { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapCenter(page.getByRole('button', {name: /calendar/i}).first()); + const sheet = page + .getByRole('dialog') + .locator('.astryx-bottom-sheet') + .first(); + await waitForRest(sheet); + await tapCenter(page.locator('[data-title="month-year"]')); + const months = page.getByRole('listbox', {name: 'Month'}); + await expect(months).toBeVisible(); + await waitForRest(page.locator('[data-panel="wheels"]').first()); + return {driver, months, field: page.locator('input').first()}; +} + +/** A slow drag of `rows` rows up (later months), resting before the lift. */ +async function turnRows(driver: TouchDriver, column: Locator, rows: number) { + const box = await column.boundingBox(); + if (box == null) { + throw new Error('no column'); + } + const from = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Whole rows plus the pan's hysteresis. + await driver.play( + drag( + from, + {x: from.x, y: from.y - rows * WHEEL_ROW_HEIGHT - 10}, + {durationMs: 250 + rows * 100, restMs: 150}, + ), + ); +} + +async function restingLabel(column: Locator): Promise { + return column.evaluate(element => { + const id = element.getAttribute('aria-activedescendant'); + return id == null + ? null + : (document.getElementById(id)?.textContent ?? null); + }); +} + +test('a turn of the month wheel moves the chosen date, keeping its day', async ({ + page, +}) => { + const {driver, months, field} = await openWheels(page); + expect(await restingLabel(months)).toBe('March'); + await turnRows(driver, months, 1); + await expect.poll(async () => restingLabel(months)).toBe('April'); + await expect(field).toHaveValue('April 15, 2026'); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the scrolling month wheel moves only the calendar, not the date', async ({ + page, + }) => { + await openStory( + page, + storybook(), + 'core-dateinput--bottom-sheet-narrow-range', + ); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page + .getByRole('button', {name: /calendar/i}) + .first() + .click(); + await page.locator('[data-title="month-year"]').click(); + const months = page.getByRole('listbox', {name: 'Month'}); + await expect(months).toBeVisible(); + expect(await restingLabel(months)).toBe('March'); + await months.press('ArrowDown'); + await expect.poll(async () => restingLabel(months)).toBe('April'); + await expect(page.locator('input').first()).toHaveValue('March 15, 2026'); + }); +}); diff --git a/packages/core/src/DateInput/monthGeometry.ts b/packages/core/src/DateInput/monthGeometry.ts index 9138306b240a5..6fec766c74069 100644 --- a/packages/core/src/DateInput/monthGeometry.ts +++ b/packages/core/src/DateInput/monthGeometry.ts @@ -28,10 +28,12 @@ import { calendarMonthOfYear, calendarMonthsBetween, calendarMonthsInCalendarYear, + calendarShiftMonths, calendarShiftYears, calendarYearMonthOf, isGregorianAligned, } from '../utils/calendarSystem'; +import {plainDateIsAfter, plainDateIsBefore} from '../utils/plainDate'; /** How many months are reachable in either direction when unbounded. */ export const DEFAULT_MONTH_REACH = 600; @@ -218,3 +220,37 @@ export function createMonthModel(calendar: CalendarSystem): MonthModel { ? gregorianMonthModel(calendar) : temporalMonthModel(calendar); } + +/** + * `date` moved to the calendar month at `monthIndex`, keeping its day of the + * month, as UIDatePicker's month and year wheels move the date + * (`datePicker.calendar.monthYearWheels.changesDate`). A day the month lacks + * becomes the month's last day (`wheels.dayClampsToMonth`). The result stays + * inside the bounds. + * + * @example + * ``` + * dateInMonth(createMonthModel('gregory'), {year: 2026, month: 1, day: 31}, toMonthIndex(2026, 2)); + * // {year: 2026, month: 2, day: 28} + * ``` + */ +export function dateInMonth( + model: MonthModel, + date: PlainDate, + monthIndex: number, + min?: PlainDate | null, + max?: PlainDate | null, +): PlainDate { + const moved = calendarShiftMonths( + model.calendar, + date, + monthIndex - model.indexOf(date), + ); + if (min != null && plainDateIsBefore(moved, min)) { + return min; + } + if (max != null && plainDateIsAfter(moved, max)) { + return max; + } + return moved; +} diff --git a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx index 2f65d8d0545f1..54bdd1db0e466 100644 --- a/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx @@ -28,7 +28,14 @@ import { import {Profiler, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; import {readFileSync} from 'node:fs'; -import {render, screen, fireEvent, within} from '@testing-library/react'; +import { + act, + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import {InternationalizationProvider} from '../i18n'; import {DateTimeInput as CoreDateTimeInput} from './DateTimeInput'; import type {DateTimeInputProps, ISODateTimeString} from './DateTimeInput'; @@ -547,6 +554,126 @@ describe('DateTimeInput touch surface', () => { expect(timeField()).toHaveValue(''); }); + it('under a finger: moves the chosen date with the month wheel, keeping the day and the time', () => { + // UIDatePicker's wheels, under a finger (this file's coarse pointer). + const onChange = vi.fn(); + withMonthLayout(() => { + render( + , + ); + openDateSheet(); + fireEvent.click( + screen.getByRole('button', {name: /Choose month and year/}), + ); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(onChange).toHaveBeenLastCalledWith('2026-05-21T09:30'); + }); + }); + + it('under a finger: a date the month wheel moves runs changeAction after onChange, shows at once, and is busy until it settles (family:input-fields FR6)', async () => { + const calls: string[] = []; + let resolve: () => void = () => {}; + const changeAction = vi.fn(async (value: string | undefined) => { + calls.push(`changeAction ${value}`); + return new Promise(done => { + resolve = done; + }); + }); + withMonthLayout(() => { + render( + calls.push(`onChange ${value}`)} + changeAction={changeAction} + />, + ); + openDateSheet(); + fireEvent.click( + screen.getByRole('button', {name: /Choose month and year/}), + ); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + }); + await waitFor(() => { + expect(calls).toEqual([ + 'onChange 2026-05-21T09:30', + 'changeAction 2026-05-21T09:30', + ]); + }); + // Optimistic: the moved date shows while the Action is pending, though + // the controlled value is still March 21. + expect(dateField()).toHaveValue('May 21, 2026'); + expect(document.querySelector('[aria-busy="true"]')).not.toBeNull(); + await act(async () => { + resolve(); + }); + await waitFor(() => { + expect(document.querySelector('[aria-busy="true"]')).toBeNull(); + }); + }); + + it('under a finger: opening the month/year wheels on a date outside the range changes nothing', () => { + const onChange = vi.fn(); + withMonthLayout(() => { + render( + , + ); + openDateSheet(); + fireEvent.click( + screen.getByRole('button', {name: /Choose month and year/}), + ); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('under a finger: keeps a date a custom constraint refuses, while the calendar moves', () => { + const onChange = vi.fn(); + withMonthLayout(() => { + render( + !(date.getMonth() === 4 && date.getDate() === 21), + ]} + onChange={onChange} + />, + ); + openDateSheet(); + fireEvent.click( + screen.getByRole('button', {name: /Choose month and year/}), + ); + fireEvent.click( + within(screen.getByRole('listbox', {name: 'Month'})).getByText('May'), + ); + expect(onChange).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', {name: /Choose month and year/}), + ).toHaveTextContent('May 2026'); + }); + }); + it('commits 12-hour wheel changes against the controlled date', () => { const onChange = vi.fn(); render( diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index db8afac8111e5..7728e14e26787 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -84,6 +84,8 @@ import {chainEventHandlers} from '../utils/chainEventHandlers'; import {joinAriaIDs} from '../utils/inputAria'; import { plainDateFromISO, + plainDateIsEqual, + plainDateToISO, plainDateToday, type PlainDate, } from '../utils/plainDate'; @@ -118,6 +120,7 @@ import { DEFAULT_MONTH_REACH, clampIndex, createMonthModel, + dateInMonth, } from '../DateInput/monthGeometry'; import type {DateTimeInputProps, ISODateTimeString} from './DateTimeInput'; import { @@ -785,6 +788,40 @@ export function TouchDateTimeField({ [clampTimeForDate, fireChange, optimisticValue, timeForWheels], ); + // Turning the month or year wheel moves the calendar. Under a finger it also + // moves a chosen date with it, keeping its day and the time, as + // UIDatePicker's inline calendar does + // (`datePicker.calendar.monthYearWheels.changesDate`). The day clamps to the + // month and the bounds. A date a custom constraint refuses stays. + const handleWheelChange = useCallback( + (next: number) => { + setMonthIndex(next); + scrollerHandleRef.current?.scrollToMonth(next, 'auto'); + if (!isTouch || selectedDate == null) { + return; + } + const moved = dateInMonth( + monthModel, + selectedDate, + next, + parseValidISODate(calendarMin), + parseValidISODate(calendarMax), + ); + if (!plainDateIsEqual(moved, selectedDate) && !isDateDisabled(moved)) { + handleDateSelect(plainDateToISO(moved)); + } + }, + [ + isTouch, + selectedDate, + monthModel, + calendarMin, + calendarMax, + isDateDisabled, + handleDateSelect, + ], + ); + const commitWheelTime = useCallback( (rawTime: ISOTimeString) => { const nextTime = clampTimeForDate(valueParts.date, rawTime); @@ -1104,10 +1141,7 @@ export function TouchDateTimeField({ keptDay={keptDay} minMonthIndex={minMonthIndex} maxMonthIndex={maxMonthIndex} - onChange={next => { - setMonthIndex(next); - scrollerHandleRef.current?.scrollToMonth(next, 'auto'); - }} + onChange={handleWheelChange} monthLabel={t('@astryx.dateInput.monthWheel')} yearLabel={t('@astryx.dateInput.yearWheel')} isActive={activePanel === 'date' && isWheelOpen} From 202173cc745e243473c200eba7198a2158901a02 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 00:58:34 -0800 Subject: [PATCH 2/3] test(DateInput): the month/year wheels' proof waits on the sheet's own end, not on still frames before a deadline `openWheels` waited for the sheet, and then for the wheels, with the harness's `waitForRest`: three still frames within 5 s. On a loaded runner the page can present no frame for 6 s right after the sheet opens, so the wait ran out with nothing moving ("still moving", while the page held no animation). The proof now checks the sheet's open transform, and then the wheels' full opacity, once a frame with no deadline of its own, and waits on each animation's `finished` promise. A starved runner only makes it later. --- ...onthYearWheels.interaction.browser.spec.ts | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts b/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts index d4498ea609f42..3204cb7a8adfb 100644 --- a/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts +++ b/packages/core/src/DateInput/__tests__/MonthYearWheels.interaction.browser.spec.ts @@ -23,7 +23,6 @@ import { drag, openStory, touchDriver, - waitForRest, withStorybook, type TouchDriver, } from '@astryxdesign/interaction-harness'; @@ -31,6 +30,43 @@ import {WHEEL_ROW_HEIGHT} from '../wheelOptions'; const storybook = withStorybook(test); +/** + * Resolves once `locator`'s element shows `property` at `value` and no + * animation inside it is left unfinished. The page checks the value once a + * frame, then awaits each animation's own `finished` promise. It sets no + * deadline of its own, so a starved runner only makes it later. + */ +async function settlesAt(locator: Locator, property: string, value: string) { + await locator.evaluate( + async (element, [name, expected]) => { + await new Promise(resolve => { + const check = () => { + if (getComputedStyle(element).getPropertyValue(name) === expected) { + resolve(); + } else { + requestAnimationFrame(check); + } + }; + check(); + }); + for (;;) { + const unfinished = element + .getAnimations({subtree: true}) + .filter(animation => animation.playState !== 'finished'); + if (unfinished.length === 0) { + return; + } + await Promise.all( + unfinished.map(async animation => + animation.finished.catch(() => undefined), + ), + ); + } + }, + [property, value] as const, + ); +} + async function openWheels(page: Page) { await openStory( page, @@ -50,11 +86,16 @@ async function openWheels(page: Page) { .getByRole('dialog') .locator('.astryx-bottom-sheet') .first(); - await waitForRest(sheet); + // The sheet rises to its open position, where its transform is the identity. + await settlesAt(sheet, 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); await tapCenter(page.locator('[data-title="month-year"]')); const months = page.getByRole('listbox', {name: 'Month'}); await expect(months).toBeVisible(); - await waitForRest(page.locator('[data-panel="wheels"]').first()); + await settlesAt( + page.locator('[data-panel="wheels"]').first(), + 'opacity', + '1', + ); return {driver, months, field: page.locator('input').first()}; } From a98a08968ac66611ebcbf75626d3fe93526200b2 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 00:58:34 -0800 Subject: [PATCH 3/3] refactor(DateInput, DateTimeInput): the wheels' comments keep the reason The comments above both sheets' handleWheelChange keep only the reason: UIDatePicker moves a chosen date with the wheels. The day and bound clamps read from dateInMonth itself, which moves the date by the calendar's own months through calendarShiftMonths. --- packages/core/src/DateInput/TouchDateField.tsx | 8 +++----- packages/core/src/DateTimeInput/TouchDateTimeField.tsx | 7 ++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index d0c39f57ff978..d8b7dec053657 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -902,11 +902,9 @@ export function TouchDateField({ // A wheel commit steers the scroller immediately, even though it is behind // the wheels: it keeps its layout box while hidden, so by the time the - // wheels close it is already resting on the new month. - // Turning the month or year wheel moves the calendar. Under a finger it - // also moves a chosen date with it, keeping its day (UIDatePicker's inline - // calendar: datePicker.calendar.monthYearWheels.changesDate), clamped to - // the month and the bounds. A date a custom constraint refuses stays. + // 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) => { diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index 7728e14e26787..866ceebcbb80f 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -788,11 +788,8 @@ export function TouchDateTimeField({ [clampTimeForDate, fireChange, optimisticValue, timeForWheels], ); - // Turning the month or year wheel moves the calendar. Under a finger it also - // moves a chosen date with it, keeping its day and the time, as - // UIDatePicker's inline calendar does - // (`datePicker.calendar.monthYearWheels.changesDate`). The day clamps to the - // month and the bounds. A date a custom constraint refuses stays. + // UIDatePicker moves a chosen date with these wheels + // (`datePicker.calendar.monthYearWheels.changesDate`). const handleWheelChange = useCallback( (next: number) => { setMonthIndex(next);