Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2d9fb81
fix(DateInput, DateTimeInput): the sheet's calendar pages like UIDate…
vjeux Nov 5, 2026
9954d25
refactor(DateInput): the month pager's sheet split is a mode of useOw…
vjeux Nov 5, 2026
77e8a8a
refactor(DateInput): MonthPager's Effects name what they sync, and it…
vjeux Nov 5, 2026
d0da0a1
fix(DateInput): the month pager follows a trackpad through the shared…
vjeux Nov 5, 2026
2a0703f
refactor(DateInput): the scroller and the pager share one month pane
vjeux Nov 5, 2026
8669954
refactor(DateInput): the scroller and the pager share one keyboard-fo…
vjeux Nov 5, 2026
2473006
test(DateInput): the sheet split's proof samples in the page
vjeux Nov 5, 2026
7def994
fix(DateInput): the arrows' step names its FR45 gap, and the title re…
vjeux Nov 5, 2026
e40b2f8
test(DateInput): four MonthPager proofs no longer hang on a loaded ru…
vjeux Nov 5, 2026
4c21450
test(DateInput): month paging follows the active calendar's months
vjeux Nov 5, 2026
9a04272
test(DateInput): the trackpad paging cases run on fake timers
vjeux Nov 5, 2026
87374bb
test(DateInput): the toggle's re-render proof spies on the pane's hook
vjeux Nov 5, 2026
6f62fc8
test(DateInput): the month pager's tests follow main
vjeux Nov 7, 2026
7ea993f
merge: main 3918a54ad0 into the month pager
vjeux Nov 8, 2026
90051f2
merge: main c5cc8f4c43 into the month pager
vjeux Nov 9, 2026
d07b6eb
Merge main
vjeux Nov 10, 2026
b5a487a
refactor(DateInput): reach the month track without a data attribute
vjeux Nov 10, 2026
e0fc1d6
refactor(DateInput): keep the month grid in MonthScroller
vjeux Nov 11, 2026
1e91935
Merge main
vjeux Nov 11, 2026
a675cf8
chore(DateInput): plain, short comments in the month pager files
vjeux Nov 11, 2026
f26df1b
docs(DateInput): name the month grid parts that MonthScroller exports
vjeux Nov 11, 2026
75d06f9
Merge main
vjeux Nov 11, 2026
f34d87f
Merge main
vjeux Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/calendar-pages-like-uidatepicker.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions apps/storybook/stories/DateInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ISODateString | undefined>(
'2026-10-15' as ISODateString,
);
return <DateInput {...args} value={value} onChange={setValue} />;
},
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<ISODateString | undefined>(undefined);
Expand Down
72 changes: 72 additions & 0 deletions apps/storybook/stories/MonthPagerProofs.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof meta>;

/** 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 (
<div style={{display: 'grid', gap: 16, inlineSize: 360}}>
<input
aria-label="Shown month"
readOnly
style={READOUT_STYLE}
value={`${year}-${String(month).padStart(2, '0')}`}
/>
<MonthPager
monthModel={GREGORY}
minMonthIndex={FIRST}
maxMonthIndex={LAST}
initialMonthIndex={MARCH}
onVisibleMonthChange={setShown}
selectedDate={null}
today={TODAY}
isDateDisabled={() => false}
weekStartsOn={0}
onSelect={() => {}}
/>
</div>
);
},
};
42 changes: 19 additions & 23 deletions docs/specs/components/DateInput/DateInput.spec.md

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion packages/core/src/DateInput/DateInputTouch.calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import {
type ISODateTimeString,
} from '../DateTimeInput/DateTimeInput';
import {InternationalizationProvider} from '../i18n/InternationalizationProvider';
import {monthGridKeyTarget} from './MonthPane';
import {createMonthModel, dateInMonth} from './monthGeometry';
import {
type CalendarSystem,
calendarDateFromFields,
calendarDayOfMonth,
} from '../utils/calendarSystem';
import type {ISODateString} from '../utils/dateTypes';
import type {ISODateString, PlainDate} from '../utils/dateTypes';
import {plainDateAddDays, plainDateFromISO} from '../utils/plainDate';

const host = globalThis as {Temporal?: unknown};
Expand Down Expand Up @@ -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);
Expand Down
Loading