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 }; +}