diff --git a/package-lock.json b/package-lock.json
index 77ffe4ff1932b3..6723e71586f9d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -52473,6 +52473,7 @@
"license": "GPL-2.0-or-later",
"dependencies": {
"@ariakit/react": "^0.4.37",
+ "@date-fns/tz": "^1.5.0",
"@wordpress/a11y": "file:../a11y",
"@wordpress/base-styles": "file:../base-styles",
"@wordpress/components": "file:../components",
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index cbf419691bdfad..93fb26e8045b7d 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -9,6 +9,7 @@
### Bug Fix
+- DataForm: Keep the displayed calendar month of the `date` and `datetime` controls in sync when the value changes from outside the control, e.g. after an undo, a reset, or switching the edited item. [#81635](https://github.com/WordPress/gutenberg/pull/81635)
- Validated form controls: Align invalid focus styling for InputBase-based controls with the design system ([#80417](https://github.com/WordPress/gutenberg/pull/80417)).
- DataViews: Prevent the filter operator select focus ring from being clipped. [#80417](https://github.com/WordPress/gutenberg/pull/80417)
- DataForm: Send a single update per calendar interaction in the `datetime` control instead of two identical ones. Selecting or clearing a date now reveals the validation message by firing a synthetic `invalid` event on the input, rather than briefly moving focus into it and re-sending the value, and announces it to screen readers since focus stays on the calendar. [#81440](https://github.com/WordPress/gutenberg/pull/81440)
diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json
index 32d333076a56f0..4e7c6f624b2975 100644
--- a/packages/dataviews/package.json
+++ b/packages/dataviews/package.json
@@ -52,6 +52,7 @@
"sideEffects": false,
"dependencies": {
"@ariakit/react": "^0.4.37",
+ "@date-fns/tz": "^1.5.0",
"@wordpress/a11y": "file:../a11y",
"@wordpress/base-styles": "file:../base-styles",
"@wordpress/components": "file:../components",
diff --git a/packages/dataviews/src/components/dataform-controls/date.tsx b/packages/dataviews/src/components/dataform-controls/date.tsx
index 4e3e43ba214562..ee85e469bfc2c7 100644
--- a/packages/dataviews/src/components/dataform-controls/date.tsx
+++ b/packages/dataviews/src/components/dataform-controls/date.tsx
@@ -1,5 +1,8 @@
import {
+ areIntervalsOverlapping,
+ endOfMonth,
format,
+ isSameMonth,
isValid as isValidDate,
subMonths,
subDays,
@@ -29,6 +32,7 @@ import {
ValidityIndicator,
} from '@wordpress/ui';
import RelativeDateControl from './utils/relative-date-control';
+import toCalendarDate from './utils/to-calendar-date';
import useDisabledDateMatchers from './utils/use-disabled-date-matchers';
import {
OPERATOR_IN_THE_PAST,
@@ -289,13 +293,36 @@ function CalendarDateControl< Item >( {
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;
+ const {
+ timezone: { string: timezoneString },
+ } = getSettings();
const fieldValue = getValue( { item: data } );
const value = typeof fieldValue === 'string' ? fieldValue : undefined;
const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => {
const parsedDate = parseDate( value );
- return parsedDate || new Date(); // Default to current month
+ // Default to current month
+ return toCalendarDate( parsedDate || new Date(), timezoneString );
} );
+ // Follow the value when it changes, so that a change from outside the
+ // control, e.g. an undo, a reset, or switching the edited item, brings
+ // the selection into view. Months are compared in the calendar's time
+ // zone: near a month boundary, a date can belong to a different month
+ // there than in the browser's time zone.
+ useEffect( () => {
+ const parsedDate = parseDate( value );
+ if ( parsedDate ) {
+ const targetMonth = toCalendarDate( parsedDate, timezoneString );
+ setCalendarMonth( ( currentMonth ) =>
+ isSameMonth(
+ targetMonth,
+ toCalendarDate( currentMonth, timezoneString )
+ )
+ ? currentMonth
+ : targetMonth
+ );
+ }
+ }, [ value, timezoneString ] );
const [ isTouched, setIsTouched ] = useState( false );
const validityTargetRef = useRef< HTMLInputElement >( null );
@@ -326,12 +353,12 @@ function CalendarDateControl< Item >( {
const presetDate = preset.getValue();
const dateValue = formatDate( presetDate );
- setCalendarMonth( presetDate );
+ setCalendarMonth( toCalendarDate( presetDate, timezoneString ) );
onChangeCallback( dateValue );
setSelectedPresetId( preset.id );
setIsTouched( true );
},
- [ onChangeCallback ]
+ [ onChangeCallback, timezoneString ]
);
const handleManualDateChange = useCallback(
@@ -340,19 +367,17 @@ function CalendarDateControl< Item >( {
if ( newValue ) {
const parsedDate = parseDate( newValue );
if ( parsedDate ) {
- setCalendarMonth( parsedDate );
+ setCalendarMonth(
+ toCalendarDate( parsedDate, timezoneString )
+ );
}
}
setSelectedPresetId( null );
setIsTouched( true );
},
- [ onChangeCallback ]
+ [ onChangeCallback, timezoneString ]
);
- const {
- timezone: { string: timezoneString },
- } = getSettings();
-
let displayLabel = label;
if ( isValid?.required && ! markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Required' ) })`;
@@ -477,6 +502,9 @@ function CalendarDateRangeControl< Item >( {
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;
+ const {
+ timezone: { string: timezoneString },
+ } = getSettings();
const { minConstraint, maxConstraint, disabledMatchers } =
useDisabledDateMatchers( isValid, parseDate );
@@ -510,8 +538,50 @@ function CalendarDateRangeControl< Item >( {
}, [ value ] );
const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => {
- return selectedRange?.from || new Date();
+ return toCalendarDate(
+ selectedRange?.from || new Date(),
+ timezoneString
+ );
} );
+ // Follow the value when it changes, so that a change from outside the
+ // control, e.g. an undo, a reset, or switching the edited item, brings
+ // the range into view. Keep the current view when part of the new range
+ // is already visible, so that selecting the end of a cross-month range
+ // doesn't move the view away. Months are compared in the calendar's
+ // time zone: near a month boundary, a date can belong to a different
+ // month there than in the browser's time zone.
+ const [ fromValue, toValue ] = value ?? [];
+ useEffect( () => {
+ setCalendarMonth( ( currentMonth ) => {
+ const from = parseDate( fromValue );
+ const to = parseDate( toValue );
+ const targetMonth = from ?? to;
+ const currentCalendarMonth = toCalendarDate(
+ currentMonth,
+ timezoneString
+ );
+ const calendarFrom = from && toCalendarDate( from, timezoneString );
+ const calendarTo = to && toCalendarDate( to, timezoneString );
+ const isRangeVisible =
+ calendarFrom && calendarTo
+ ? areIntervalsOverlapping(
+ { start: calendarFrom, end: calendarTo },
+ {
+ start: startOfMonth( currentCalendarMonth ),
+ end: endOfMonth( currentCalendarMonth ),
+ },
+ { inclusive: true }
+ )
+ : [ calendarFrom, calendarTo ].some(
+ ( date ) =>
+ date &&
+ isSameMonth( date, currentCalendarMonth )
+ );
+ return targetMonth && ! isRangeVisible
+ ? toCalendarDate( targetMonth, timezoneString )
+ : currentMonth;
+ } );
+ }, [ fromValue, toValue, timezoneString ] );
const [ isTouched, setIsTouched ] = useState( false );
const fromInputRef = useRef< HTMLInputElement >( null );
@@ -549,12 +619,12 @@ function CalendarDateRangeControl< Item >( {
const handlePresetClick = useCallback(
( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => {
const [ startDate, endDate ] = preset.getValue();
- setCalendarMonth( startDate );
+ setCalendarMonth( toCalendarDate( startDate, timezoneString ) );
updateDateRange( startDate, endDate );
setSelectedPresetId( preset.id );
setIsTouched( true );
},
- [ updateDateRange ]
+ [ updateDateRange, timezoneString ]
);
const handleManualDateChange = useCallback(
@@ -571,18 +641,18 @@ function CalendarDateRangeControl< Item >( {
if ( newValue ) {
const parsedDate = parseDate( newValue );
if ( parsedDate ) {
- setCalendarMonth( parsedDate );
+ setCalendarMonth(
+ toCalendarDate( parsedDate, timezoneString )
+ );
}
}
setSelectedPresetId( null );
setIsTouched( true );
},
- [ value, updateDateRange ]
+ [ value, updateDateRange, timezoneString ]
);
- const { timezone } = getSettings();
-
let displayLabel = label;
if ( field.isValid?.required && ! markWhenOptional ) {
displayLabel = `${ label } (${ __( 'Required' ) })`;
@@ -687,7 +757,7 @@ function CalendarDateRangeControl< Item >( {
onValueChange={ onSelectCalendarRange }
month={ calendarMonth }
onMonthChange={ setCalendarMonth }
- timeZone={ timezone.string || undefined }
+ timeZone={ timezoneString || undefined }
weekStartsOn={ weekStartsOn }
disabled={ disabled || disabledMatchers }
/>
diff --git a/packages/dataviews/src/components/dataform-controls/datetime.tsx b/packages/dataviews/src/components/dataform-controls/datetime.tsx
index 45b41780a99dfc..653570eb5172c6 100644
--- a/packages/dataviews/src/components/dataform-controls/datetime.tsx
+++ b/packages/dataviews/src/components/dataform-controls/datetime.tsx
@@ -1,3 +1,4 @@
+import { isSameMonth } from 'date-fns';
import {
BaseControl,
privateApis as componentsPrivateApis,
@@ -10,6 +11,7 @@ import { Calendar, Stack } from '@wordpress/ui';
import type { DataFormControlProps, FormatDatetime } from '../../types';
import { OPERATOR_IN_THE_PAST, OPERATOR_OVER } from '../../constants';
import RelativeDateControl from './utils/relative-date-control';
+import toCalendarDate from './utils/to-calendar-date';
import useDisabledDateMatchers from './utils/use-disabled-date-matchers';
import getCustomValidity from './utils/get-custom-validity';
import parseDateTime from '../../field-types/utils/parse-date-time';
@@ -39,11 +41,34 @@ function CalendarDateTimeControl< Item >( {
const disabled = field.isDisabled( { item: data, field } );
const fieldValue = getValue( { item: data } );
const value = typeof fieldValue === 'string' ? fieldValue : undefined;
+ const {
+ timezone: { string: timezoneString },
+ } = getSettings();
const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => {
const parsedDate = parseDateTime( value );
- return parsedDate || new Date(); // Default to current month
+ // Default to current month
+ return toCalendarDate( parsedDate || new Date(), timezoneString );
} );
+ // Follow the value when it changes, so that a change from outside the
+ // control, e.g. an undo, a reset, or switching the edited item, brings
+ // the selection into view. Months are compared in the calendar's time
+ // zone: near a month boundary, a date can belong to a different month
+ // there than in the browser's time zone.
+ useEffect( () => {
+ const parsedDate = parseDateTime( value );
+ if ( parsedDate ) {
+ const targetMonth = toCalendarDate( parsedDate, timezoneString );
+ setCalendarMonth( ( currentMonth ) =>
+ isSameMonth(
+ targetMonth,
+ toCalendarDate( currentMonth, timezoneString )
+ )
+ ? currentMonth
+ : targetMonth
+ );
+ }
+ }, [ value, timezoneString ] );
const inputControlRef = useRef< HTMLInputElement >( null );
const validationTimeoutRef =
@@ -122,22 +147,21 @@ function CalendarDateTimeControl< Item >( {
// Update calendar month to match
const parsedDate = parseDateTime( dateTime.toISOString() );
if ( parsedDate ) {
- setCalendarMonth( parsedDate );
+ setCalendarMonth(
+ toCalendarDate( parsedDate, timezoneString )
+ );
}
} else {
onChangeCallback( undefined );
}
},
- [ onChangeCallback ]
+ [ onChangeCallback, timezoneString ]
);
const { format: fieldFormat } = field;
const weekStartsOn =
( fieldFormat as FormatDatetime ).weekStartsOn ??
getSettings().l10n.startOfWeek;
- const {
- timezone: { string: timezoneString },
- } = getSettings();
let displayLabel = label;
if ( isValid?.required && ! markWhenOptional && ! hideLabelFromVision ) {
diff --git a/packages/dataviews/src/components/dataform-controls/test/date.tsx b/packages/dataviews/src/components/dataform-controls/test/date.tsx
new file mode 100644
index 00000000000000..275c8c1ab9e961
--- /dev/null
+++ b/packages/dataviews/src/components/dataform-controls/test/date.tsx
@@ -0,0 +1,268 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { getSettings, setSettings } from '@wordpress/date';
+import { useState } from '@wordpress/element';
+import normalizeFields from '../../../field-types';
+import { OPERATOR_BETWEEN } from '../../../constants';
+import DateControl from '../date';
+import type { DataFormControlProps } from '../../../types';
+
+jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) );
+
+const noop = () => {};
+
+type TestItem = {
+ published?: string | [ string, string ];
+};
+
+const field = normalizeFields< TestItem >( [
+ {
+ id: 'published',
+ label: 'Published',
+ type: 'date',
+ },
+] )[ 0 ];
+
+const getMonthGrid = ( monthLabel: string ) =>
+ screen.getByRole( 'grid', { name: monthLabel } );
+
+const fullDateFormatter = new Intl.DateTimeFormat( 'en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+} );
+
+const getDayButton = ( date: Date ) =>
+ screen.getByRole( 'button', {
+ name: new RegExp( fullDateFormatter.format( date ) ),
+ } );
+
+function RangeHarness( {
+ initialValue,
+}: {
+ initialValue: [ string, string ];
+} ) {
+ const [ data, setData ] = useState< TestItem >( {
+ published: initialValue,
+ } );
+ const onChange: DataFormControlProps< TestItem >[ 'onChange' ] = (
+ edits
+ ) => setData( ( current ) => ( { ...current, ...edits } ) as TestItem );
+ return (
+
+ );
+}
+
+describe( 'DateControl', () => {
+ it( 'should move the calendar to the month of a value changed from outside the control', () => {
+ const { rerender } = render(
+
+ );
+
+ expect( getMonthGrid( 'March 2024' ) ).toBeInTheDocument();
+
+ // External value change, e.g. an undo, a reset, or switching the
+ // edited item.
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'November 2024' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should keep the displayed month when the value is cleared from outside the control', () => {
+ const { rerender } = render(
+
+ );
+
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'March 2024' ) ).toBeInTheDocument();
+ } );
+
+ describe( 'with the `between` operator', () => {
+ it( 'should move the calendar to the start of a range changed from outside the control', () => {
+ const { rerender } = render(
+
+ );
+
+ expect( getMonthGrid( 'March 2024' ) ).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'November 2024' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should keep the displayed month while selecting the end of a cross-month range', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ // Start a new range in the displayed month…
+ await user.click( getDayButton( new Date( 2024, 2, 20 ) ) );
+ // …navigate to the next month…
+ await user.click(
+ screen.getByRole( 'button', { name: /next month/i } )
+ );
+ expect( getMonthGrid( 'April 2024' ) ).toBeInTheDocument();
+
+ // …and select the end of the range there. The view must not
+ // jump back to the month of the range start.
+ await user.click( getDayButton( new Date( 2024, 3, 1 ) ) );
+
+ expect( getMonthGrid( 'April 2024' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should keep the displayed month when it falls inside a range changed from outside the control', () => {
+ const { rerender } = render(
+
+ );
+
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'February 2026' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'with a site time zone ahead of the browser', () => {
+ let originalSettings: ReturnType< typeof getSettings >;
+
+ beforeAll( () => {
+ originalSettings = getSettings();
+ // UTC+14, ahead of every possible browser time zone.
+ setSettings( {
+ ...originalSettings,
+ timezone: {
+ ...originalSettings.timezone,
+ string: 'Pacific/Kiritimati',
+ offset: 14,
+ },
+ } );
+ } );
+
+ afterAll( () => {
+ setSettings( originalSettings );
+ } );
+
+ it( 'should move the calendar to the month of a value changed across a month boundary', () => {
+ const { rerender } = render(
+
+ );
+
+ expect( getMonthGrid( 'February 2026' ) ).toBeInTheDocument();
+
+ // March 1 in the site time zone is still February in the
+ // browser's, so a comparison in the wrong time zone keeps the
+ // calendar on February and hides the selected day.
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'March 2026' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /March 1, 2026/ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should move the calendar when a range changes across a month boundary', () => {
+ const { rerender } = render(
+
+ );
+
+ expect( getMonthGrid( 'February 2026' ) ).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect( getMonthGrid( 'March 2026' ) ).toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/packages/dataviews/src/components/dataform-controls/test/datetime.tsx b/packages/dataviews/src/components/dataform-controls/test/datetime.tsx
new file mode 100644
index 00000000000000..a84542fd342aa1
--- /dev/null
+++ b/packages/dataviews/src/components/dataform-controls/test/datetime.tsx
@@ -0,0 +1,100 @@
+import { render, screen } from '@testing-library/react';
+import { getSettings, setSettings } from '@wordpress/date';
+import normalizeFields from '../../../field-types';
+import DateTime from '../datetime';
+
+jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) );
+
+const noop = () => {};
+
+type TestItem = {
+ published?: string;
+};
+
+const field = normalizeFields< TestItem >( [
+ {
+ id: 'published',
+ label: 'Published',
+ type: 'datetime',
+ },
+] )[ 0 ];
+
+describe( 'DateTime control', () => {
+ it( 'should move the calendar to the month of a value changed from outside the control', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(
+ screen.getByRole( 'grid', { name: 'March 2024' } )
+ ).toBeInTheDocument();
+
+ // External value change, e.g. an undo, a reset, or switching the
+ // edited item.
+ rerender(
+
+ );
+
+ expect(
+ screen.getByRole( 'grid', { name: 'November 2024' } )
+ ).toBeInTheDocument();
+ } );
+
+ describe( 'with a site time zone ahead of the browser', () => {
+ let originalSettings: ReturnType< typeof getSettings >;
+
+ beforeAll( () => {
+ originalSettings = getSettings();
+ // UTC+14, ahead of every possible browser time zone.
+ setSettings( {
+ ...originalSettings,
+ timezone: {
+ ...originalSettings.timezone,
+ string: 'Pacific/Kiritimati',
+ offset: 14,
+ },
+ } );
+ } );
+
+ afterAll( () => {
+ setSettings( originalSettings );
+ } );
+
+ it( 'should move the calendar to the month of a value changed across a month boundary', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(
+ screen.getByRole( 'grid', { name: 'February 2026' } )
+ ).toBeInTheDocument();
+
+ // This instant is March 1 in the site time zone, but still
+ // February in the browser's, so a comparison in the wrong time
+ // zone keeps the calendar on February and hides the selected day.
+ rerender(
+
+ );
+
+ expect(
+ screen.getByRole( 'grid', { name: 'March 2026' } )
+ ).toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/packages/dataviews/src/components/dataform-controls/utils/to-calendar-date.ts b/packages/dataviews/src/components/dataform-controls/utils/to-calendar-date.ts
new file mode 100644
index 00000000000000..9a4039c3a720ae
--- /dev/null
+++ b/packages/dataviews/src/components/dataform-controls/utils/to-calendar-date.ts
@@ -0,0 +1,16 @@
+import { TZDate } from '@date-fns/tz';
+
+/**
+ * Expresses a date in the calendar's time zone, so that computing or
+ * comparing calendar months matches the month grid that the calendar
+ * displays.
+ *
+ * @param date The date to express in the calendar's time zone.
+ * @param timeZone The calendar's IANA time zone. When empty, the date is
+ * returned unchanged, matching the calendar's own fallback
+ * to the browser time zone.
+ * @return The date to use for calendar month computations.
+ */
+export default function toCalendarDate( date: Date, timeZone?: string ): Date {
+ return timeZone ? new TZDate( date, timeZone ) : date;
+}
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 54e29cc9af7f99..3edbe2845da3b8 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -29,6 +29,7 @@
- `Autocomplete.Input`, `Combobox.Input`: Omit the `type` prop, since the combobox role is only valid on `input type="text"` ([#80636](https://github.com/WordPress/gutenberg/pull/80636)).
- `Card`: Use the normal neutral surface stroke for the default border. ([#81746](https://github.com/WordPress/gutenberg/pull/81746))
+- `Calendar`, `RangeCalendar`: Preserve day focus when a controlled month change removes the focused day. ([#81635](https://github.com/WordPress/gutenberg/pull/81635))
### Documentation
diff --git a/packages/ui/src/calendar/calendar.tsx b/packages/ui/src/calendar/calendar.tsx
index 41afbca7d78c71..b51fa700fab6d2 100644
--- a/packages/ui/src/calendar/calendar.tsx
+++ b/packages/ui/src/calendar/calendar.tsx
@@ -5,6 +5,7 @@ import { COMMON_PROPS } from './utils/constants';
import { clampNumberOfMonths } from './utils/misc';
import { useControlledValue } from './utils/use-controlled-value';
import { useLocalizationProps } from './utils/use-localization-props';
+import { usePreserveDayFocus } from './utils/use-preserve-day-focus';
import { RootContext } from './utils/root-context';
import type { CalendarProps, OnValueChangeHandler } from './types';
@@ -25,6 +26,7 @@ export const Calendar = forwardRef< HTMLDivElement, CalendarProps >(
numberOfMonths = 1,
locale = enUS,
timeZone,
+ month,
render,
labels: customLabels,
...props
@@ -65,10 +67,11 @@ export const Calendar = forwardRef< HTMLDivElement, CalendarProps >(
value: valueProp,
onChange,
} );
+ const dayFocusProps = usePreserveDayFocus( ref, month );
const rootContextValue = useMemo(
- () => ( { render, ref } ),
- [ render, ref ]
+ () => ( { render, ref: dayFocusProps.ref } ),
+ [ render, dayFocusProps.ref ]
);
return (
@@ -79,10 +82,13 @@ export const Calendar = forwardRef< HTMLDivElement, CalendarProps >(
{ ...props }
role="application"
mode="single"
+ month={ month }
numberOfMonths={ clampNumberOfMonths( numberOfMonths ) }
labels={ labels }
selected={ selected ?? undefined }
onSelect={ setSelected }
+ onDayFocus={ dayFocusProps.onDayFocus }
+ onDayBlur={ dayFocusProps.onDayBlur }
/>
);
diff --git a/packages/ui/src/calendar/range-calendar.tsx b/packages/ui/src/calendar/range-calendar.tsx
index 977616e71ef406..d58ebc396b4f4e 100644
--- a/packages/ui/src/calendar/range-calendar.tsx
+++ b/packages/ui/src/calendar/range-calendar.tsx
@@ -6,6 +6,7 @@ import { COMMON_PROPS, MODIFIER_CLASSNAMES } from './utils/constants';
import { clampNumberOfMonths } from './utils/misc';
import { useControlledValue } from './utils/use-controlled-value';
import { useLocalizationProps } from './utils/use-localization-props';
+import { usePreserveDayFocus } from './utils/use-preserve-day-focus';
import { RootContext } from './utils/root-context';
import type {
RangeCalendarProps,
@@ -141,6 +142,7 @@ export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >(
disabled,
locale = enUS,
timeZone,
+ month,
render,
labels: customLabels,
...props
@@ -181,6 +183,7 @@ export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >(
value: valueProp,
onChange,
} );
+ const dayFocusProps = usePreserveDayFocus( ref, month );
const [ hoveredDate, setHoveredDate ] = useState< Date | undefined >(
undefined
@@ -205,8 +208,8 @@ export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >(
}, [ previewRange ] );
const rootContextValue = useMemo(
- () => ( { render, ref } ),
- [ render, ref ]
+ () => ( { render, ref: dayFocusProps.ref } ),
+ [ render, dayFocusProps.ref ]
);
return (
@@ -217,6 +220,7 @@ export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >(
{ ...props }
role="application"
mode="range"
+ month={ month }
numberOfMonths={ clampNumberOfMonths( numberOfMonths ) }
disabled={ disabled }
excludeDisabled={ excludeDisabled }
@@ -225,6 +229,8 @@ export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >(
labels={ labels }
selected={ selected ?? undefined }
onSelect={ setSelected }
+ onDayFocus={ dayFocusProps.onDayFocus }
+ onDayBlur={ dayFocusProps.onDayBlur }
onDayMouseEnter={ ( date ) => setHoveredDate( date ) }
onDayMouseLeave={ () => setHoveredDate( undefined ) }
modifiers={ modifiers }
diff --git a/packages/ui/src/calendar/test/calendar.test.tsx b/packages/ui/src/calendar/test/calendar.test.tsx
index 4c61c1929db325..43036499c2d582 100644
--- a/packages/ui/src/calendar/test/calendar.test.tsx
+++ b/packages/ui/src/calendar/test/calendar.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, within } from '@testing-library/react';
+import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
startOfDay,
@@ -568,6 +568,133 @@ describe( 'Calendar', () => {
} );
describe( 'Keyboard focus and navigation', () => {
+ it( 'should keep day focus when the controlled month changes', () => {
+ const selectedNextMonth = addMonths( today, 1 );
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+
+ rerender(
+
+ );
+
+ expect( getDateButton( selectedNextMonth ) ).toHaveFocus();
+ } );
+
+ it( 'should keep tracking focus when the controlled date changes within the displayed month', () => {
+ const selectedNextMonth = addMonths( today, 1 );
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender( );
+ rerender(
+
+ );
+
+ expect( getDateButton( selectedNextMonth ) ).toHaveFocus();
+ } );
+
+ it( 'should keep tracking focus when the focused day remains in a later displayed month', () => {
+ const selectedNextMonth = addMonths( today, 1 );
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender(
+
+ );
+ expect( getDateButton( today ) ).toHaveFocus();
+ rerender(
+
+ );
+
+ expect( getDateButton( selectedNextMonth ) ).toHaveFocus();
+ } );
+
+ it( 'should not move outside focus when the controlled month changes', () => {
+ const { rerender } = render(
+ <>
+
+
+ >
+ );
+ const outsideControl = screen.getByRole( 'button', {
+ name: 'Outside control',
+ } );
+
+ act( () => outsideControl.focus() );
+ rerender(
+ <>
+
+
+ >
+ );
+
+ expect( outsideControl ).toHaveFocus();
+ } );
+
+ it( 'should keep focus inside the component when the new month has no enabled day', () => {
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender(
+
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: /previous month/i } )
+ ).toHaveFocus();
+ } );
+
+ it( 'should keep focus inside when the controlled month changes and the calendar becomes fully disabled', () => {
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender(
+
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: /previous month/i } )
+ ).toHaveFocus();
+ } );
+
it( 'should auto-focus the selected day when the `autoFocus` prop is set to `true`', async () => {
// eslint-disable-next-line jsx-a11y/no-autofocus
render( );
diff --git a/packages/ui/src/calendar/test/range-calendar.test.tsx b/packages/ui/src/calendar/test/range-calendar.test.tsx
index 0a93d05bab924c..f4411fa64bb035 100644
--- a/packages/ui/src/calendar/test/range-calendar.test.tsx
+++ b/packages/ui/src/calendar/test/range-calendar.test.tsx
@@ -1,4 +1,10 @@
-import { render, screen, within, renderHook } from '@testing-library/react';
+import {
+ act,
+ render,
+ screen,
+ within,
+ renderHook,
+} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
startOfDay,
@@ -1093,6 +1099,59 @@ describe( 'RangeCalendar', () => {
} );
describe( 'Keyboard focus and navigation', () => {
+ it( 'should keep day focus when the controlled month changes', () => {
+ const nextRange = {
+ from: addMonths( today, 1 ),
+ to: addMonths( tomorrow, 1 ),
+ };
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender(
+
+ );
+
+ expect( getDateButton( nextRange.from ) ).toHaveFocus();
+ } );
+
+ it( 'should keep tracking focus when the focused day remains in a later displayed month', () => {
+ const nextRange = {
+ from: addMonths( today, 1 ),
+ to: addMonths( tomorrow, 1 ),
+ };
+ const { rerender } = render(
+
+ );
+
+ act( () => getDateButton( today ).focus() );
+ rerender(
+
+ );
+ expect( getDateButton( today ) ).toHaveFocus();
+ rerender(
+
+ );
+
+ expect( getDateButton( nextRange.from ) ).toHaveFocus();
+ } );
+
it( 'should auto-focus the selected day when the `autoFocus` prop is set to `true`', async () => {
render(
;
+
+/**
+ * Keeps keyboard focus inside the calendar when a controlled month change
+ * removes the focused day from the DOM.
+ *
+ * @param forwardedRef Ref forwarded to the calendar root.
+ * @param month The controlled first displayed month.
+ *
+ * @see https://github.com/gpbl/react-day-picker/issues/3009
+ */
+export function usePreserveDayFocus(
+ forwardedRef: Ref< HTMLDivElement >,
+ month: Date | undefined
+) {
+ const rootRef = useRef< HTMLDivElement >( null );
+ const hadDayFocusRef = useRef( false );
+ const mergedRef = useMergeRefs( [ rootRef, forwardedRef ] );
+ const monthIndex = month
+ ? month.getFullYear() * 12 + month.getMonth()
+ : undefined;
+
+ const onDayFocus: DayFocusHandler = useCallback( () => {
+ hadDayFocusRef.current = true;
+ }, [] );
+ const onDayBlur: DayFocusHandler = useCallback( () => {
+ hadDayFocusRef.current = false;
+ }, [] );
+
+ useEffect( () => {
+ if ( ! hadDayFocusRef.current ) {
+ return;
+ }
+
+ const root = rootRef.current;
+ if ( ! root ) {
+ return;
+ }
+ const { activeElement, body } = root.ownerDocument;
+ if ( activeElement !== body ) {
+ const grid = activeElement?.closest( '[role="grid"]' );
+ hadDayFocusRef.current = !! grid && root.contains( grid );
+ return;
+ }
+ hadDayFocusRef.current = false;
+
+ const focusTarget = root.querySelector< HTMLButtonElement >(
+ '[role="grid"] button[tabindex="0"]:not(:disabled)'
+ );
+ const enabledControl = root.querySelector< HTMLButtonElement >(
+ 'button:not(:disabled):not([aria-disabled="true"])'
+ );
+ const focusableControl = root.querySelector< HTMLButtonElement >(
+ 'button:not(:disabled)'
+ );
+
+ ( focusTarget ?? enabledControl ?? focusableControl )?.focus();
+ }, [ monthIndex ] );
+
+ return { ref: mergedRef, onDayFocus, onDayBlur };
+}