diff --git a/change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json b/change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json
new file mode 100644
index 00000000000000..41b3103ebdd3b1
--- /dev/null
+++ b/change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json
@@ -0,0 +1,6 @@
+{
+ "type": "patch",
+ "comment": "fix: restore class-independent Calendar focus, two-dimensional grid arrows that skip unfocusable cells, and consumer keyboard event ordering and cancellation",
+ "packageName": "@fluentui/react-calendar-preview",
+ "email": "dmytrokirpa@microsoft.com"
+}
diff --git a/packages/react-components/react-calendar-preview/library/docs/Spec.md b/packages/react-components/react-calendar-preview/library/docs/Spec.md
index 00f44b87442842..7f8f617aa02a87 100644
--- a/packages/react-components/react-calendar-preview/library/docs/Spec.md
+++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md
@@ -248,6 +248,37 @@ Calendar owns selected-date, displayed-date, and picker-view state. The day and
The styled components use Tabster arrow-navigation groups. Base state hooks are exported for a headless layer and intentionally leave picker resolution or roving-focus behavior to the caller.
+Month and year cells own two-dimensional arrow navigation in both layers. Horizontal arrows stay in the current
+four-cell row, respect RTL, and do not wrap; vertical arrows stay in the same column. Unavailable cells are skipped
+unless `allFocusable` is enabled, which permits focus but not selection. Consumer cell handlers can cancel the
+built-in action with `preventDefault()`. Modified arrows and editable descendants are left untouched.
+Handled arrows, including boundary keys, are prevented so Tabster or a native focusgroup does not move focus again.
+
+Owned day/month/year arrows dispatch the composed cell keyboard handler during capture, before a native grid
+owner's bubbling listener. Consumer `onKeyDownCapture` runs first; consumer `onKeyDown` runs exactly once with the
+cell as `currentTarget`, and `preventDefault()` cancels movement without an intermediate focus hop. Day slot
+handlers, including handlers on custom descendants, and custom year-renderer handlers run before navigation
+with the correct `currentTarget`. Only handlers on the target's ancestor path run.
+`preventDefault()` cancels
+navigation without suppressing later consumer callbacks; `stopPropagation()` suppresses later callbacks,
+but a cell bubbling handler can stop propagation without cancelling Calendar navigation.
+Arrows from independently nested grids or descendant `[focusgroup]` owners, including `focusgroup="none"`,
+remain in bubbling without Calendar navigation. A focusgroup on the Calendar grid itself or outside that grid
+does not change Calendar's arrow ownership.
+This ordering relies on React 17+ capture delegation. There is no special React 16 bridge for native grid owners.
+Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch.
+
+Month/year navigation continues past cells that cannot receive focus, including hidden cells, while allowing
+programmatic navigation to cells with `tabIndex="-1"`.
+
+Editable keyboard detection honors the nearest `contenteditable` boundary: descendants of
+`contenteditable="false"` remain navigable even inside an editable ancestor. `inherit` and invalid or
+case-insensitive values are treated according to the nearest explicit editable boundary.
+
+The day and month base hooks reserve `data-fui-calendar-picker="day"` and `data-fui-calendar-picker="month"` on their roots.
+Calendar uses these behavioral markers, not styled class names, to restore focus when a focused picker is hidden.
+Custom renderers must preserve the base root props. Moving focus outside Calendar cancels pending restoration.
+
### DOM
The default DOM is equivalent to:
@@ -256,7 +287,7 @@ The default DOM is equivalent to:
Selected: June 18, 2025
-
+
June 2025
@@ -279,7 +310,7 @@ The default DOM is equivalent to:
-
+
... year heading and previous/next year buttons ...
... month gridcells ...
diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx
new file mode 100644
index 00000000000000..a7f66ba18f8eca
--- /dev/null
+++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx
@@ -0,0 +1,17 @@
+/*
+ * @jest-environment node
+ */
+
+import * as React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { Calendar } from './Calendar';
+
+describe('Calendar without a target document', () => {
+ it('renders the behavioral picker markers during server-side rendering', () => {
+ const today = new Date(2020, 8, 18);
+ const markup = renderToStaticMarkup(
);
+
+ expect(markup).toContain('data-fui-calendar-picker="day"');
+ expect(markup).toContain('data-fui-calendar-picker="month"');
+ });
+});
diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx
index 879d78ed31fe2d..9e743d3ca4310c 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx
+++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx
@@ -12,8 +12,12 @@ import { calendarMonthGridCellClassNames } from '../CalendarMonthGridCell/useCal
import { calendarYearGridCellClassNames } from '../CalendarYearGridCell/useCalendarYearGridCellStyles.styles';
import type { JSXElement } from '@fluentui/react-utilities';
-const mountFluent = (element: JSXElement) => {
- mount(
{element} );
+const mountFluent = (element: JSXElement, dir: 'ltr' | 'rtl' = 'ltr') => {
+ mount(
+
+ {element}
+ ,
+ );
};
/**
@@ -33,6 +37,127 @@ const goToToday = `.${calendarClassNames.goToTodayButton}`;
describe('Calendar', () => {
describe('day grid keyboard navigation', () => {
+ it('keeps enclosing day-label callbacks when a nested Calendar handles an arrow', () => {
+ const onOuterKeyDown = cy.stub().as('onOuterKeyDown');
+ mountFluent(
+
+ date.getTime() === today.getTime()
+ ? {
+ dayLabel: {
+ onKeyDown: onOuterKeyDown,
+ children: ,
+ },
+ }
+ : {},
+ }}
+ />,
+ );
+
+ cy.get(day('September 18, 2020'))
+ .find('[role="grid"] button[aria-label="September 18, 2020"]')
+ .last()
+ .focus()
+ .realPress('ArrowRight');
+ cy.get('@onOuterKeyDown').should('have.been.calledOnce');
+ });
+
+ it('does not forward cell-targeted arrows to the child button', () => {
+ const cancel = cy.stub().callsFake((event: React.KeyboardEvent) => event.preventDefault());
+ mountFluent(
+ ({ button: { onKeyDownCapture: cancel, onKeyDown: cancel } }) }}
+ />,
+ );
+ cy.get(day('September 18, 2020')).focus().realPress('ArrowRight');
+ cy.get(day('September 19, 2020')).should('be.focused');
+ cy.wrap(cancel).should('not.have.been.called');
+ });
+
+ (['capture', 'bubble'] as const).forEach(phase => {
+ it(`honors dayLabel ${phase} cancellation and preserves consumer bubbling`, () => {
+ const capture = cy.stub().callsFake((event: React.KeyboardEvent) => {
+ if (phase === 'capture') {
+ event.preventDefault();
+ }
+ });
+ const bubble = cy.stub().callsFake((event: React.KeyboardEvent) => event.preventDefault());
+ mountFluent(
+ ({
+ dayLabel: { tabIndex: 0, title: 'Focusable day', onKeyDownCapture: capture, onKeyDown: bubble },
+ }),
+ }}
+ />,
+ );
+ cy.get(day('September 18, 2020')).find('[title="Focusable day"]').focus().realPress('ArrowRight');
+ cy.get(day('September 18, 2020')).find('[title="Focusable day"]').should('be.focused');
+ cy.wrap(capture).should('have.been.calledOnce');
+ cy.wrap(bubble).should('have.been.calledOnce');
+ });
+ });
+
+ it('preserves day movement when the cell consumer only stops propagation', () => {
+ mountFluent(
+ ({ onKeyDown: event => event.stopPropagation() }) }}
+ />,
+ );
+ cy.get(day('September 18, 2020')).focus().realPress('ArrowRight');
+ cy.get(day('September 19, 2020')).should('be.focused');
+ });
+
+ (['capture', 'bubble'] as const).forEach(phase => {
+ it(`honors custom dayLabel child ${phase} cancellation before navigation`, () => {
+ const onKeyDownCapture = cy.stub().callsFake((event: React.KeyboardEvent) => {
+ if (phase === 'capture') {
+ event.preventDefault();
+ }
+ });
+ const onKeyDown = cy.stub().callsFake((event: React.KeyboardEvent) => {
+ if (phase === 'bubble') {
+ event.preventDefault();
+ }
+ });
+ mountFluent(
+ ({
+ dayLabel: {
+ children: (
+
+ 18
+
+ ),
+ },
+ }),
+ }}
+ />,
+ );
+ cy.get(day('September 18, 2020')).find('[title="Custom day content"]').focus().realPress('ArrowRight');
+ cy.get(day('September 18, 2020')).find('[title="Custom day content"]').should('be.focused');
+ cy.wrap(onKeyDownCapture).should('have.been.calledOnce');
+ cy.wrap(onKeyDown).should('have.been.calledOnce');
+ });
+ });
+
it('pages from the arrow-focused day rather than the original anchor', () => {
const onDisplayedDateChange = cy.stub().as('onDisplayedDateChange');
mountFluent( );
@@ -243,6 +368,176 @@ describe('Calendar', () => {
});
describe('month and year pickers', () => {
+ (['month', 'year'] as const).forEach(picker => {
+ const cells = '[data-fui-calendar-picker="month"] button[role="gridcell"]';
+ const openYearPicker = () => {
+ if (picker === 'year') {
+ cy.get(`.${calendarMonthClassNames.heading}`).click();
+ }
+ };
+
+ it(`skips hidden ${picker} cells and still focuses tabIndex=-1 candidates`, () => {
+ mountFluent( );
+ openYearPicker();
+
+ cy.get(cells).eq(1).invoke('prop', 'hidden', true).should('not.be.visible');
+ cy.get(cells).eq(2).invoke('attr', 'tabindex', '-1');
+ cy.get(cells).eq(0).focus().realPress('ArrowRight');
+ cy.get(cells).eq(2).should('be.focused');
+ });
+
+ it(`moves the ${picker} grid exactly once per arrow without wrapping rows or columns`, () => {
+ const onSelectDate = cy.stub().as('onSelectDate');
+ const onDisplayedDateChange = cy.stub().as('onDisplayedDateChange');
+ mountFluent(
+ ,
+ );
+ openYearPicker();
+
+ cy.get(cells).eq(0).focus().realPress('ArrowRight');
+ cy.get(cells).eq(1).should('be.focused').realPress('ArrowDown');
+ cy.get(cells).eq(5).should('be.focused').realPress('ArrowLeft');
+ cy.get(cells).eq(4).should('be.focused').realPress('ArrowUp');
+ cy.get(cells).eq(0).should('be.focused').realPress('ArrowLeft');
+ cy.get(cells).eq(0).should('be.focused');
+
+ cy.get(cells).eq(3).focus().realPress('ArrowRight');
+ cy.get(cells).eq(3).should('be.focused');
+ cy.get(cells).eq(8).focus().realPress('ArrowDown');
+ cy.get(cells).eq(8).should('be.focused');
+ cy.get('@onSelectDate').should('not.have.been.called');
+ cy.get('@onDisplayedDateChange').should('not.have.been.called');
+ });
+
+ it(`preserves RTL arrow navigation in the ${picker} grid`, () => {
+ mountFluent( , 'rtl');
+ openYearPicker();
+
+ cy.get(cells).eq(0).focus().realPress('ArrowLeft');
+ cy.get(cells).eq(1).should('be.focused').realPress('ArrowDown');
+ cy.get(cells).eq(5).should('be.focused').realPress('ArrowRight');
+ cy.get(cells).eq(4).should('be.focused');
+ });
+
+ it(`preserves Tabster's single Tab stop for the ${picker} grid`, () => {
+ mountFluent(
+ <>
+
+ After Calendar
+ >,
+ );
+ openYearPicker();
+
+ cy.get(cells).eq(0).focus().realPress('ArrowRight');
+ cy.get(cells).eq(1).should('be.focused').realPress('Tab');
+ cy.contains('button', 'After Calendar').should('be.focused');
+ });
+
+ it(`honors consumer capture cancellation in the ${picker} grid`, () => {
+ const onKeyDownCapture = cy.stub().callsFake((event: React.KeyboardEvent) => event.preventDefault());
+ mountFluent(
+ ,
+ );
+ openYearPicker();
+
+ cy.get(cells).eq(5).focus().realPress('ArrowDown');
+ cy.get(cells).eq(5).should('be.focused');
+ cy.wrap(onKeyDownCapture).should('have.been.calledOnce');
+ });
+
+ [false, true].forEach(allFocusable => {
+ it(`respects unavailable ${picker} cells with allFocusable=${allFocusable}`, () => {
+ const onSelectDate = cy.stub().as('onSelectDate');
+ mountFluent(
+ ,
+ );
+ openYearPicker();
+
+ cy.get(cells).eq(1).focus().realPress('ArrowLeft');
+ cy.get(cells)
+ .eq(allFocusable ? 0 : 1)
+ .should('be.focused');
+ if (allFocusable) {
+ cy.get(cells).eq(0).should('be.enabled').and('have.attr', 'aria-disabled', 'true').realPress('Enter');
+ cy.get('@onSelectDate').should('not.have.been.called');
+ }
+ });
+ });
+
+ if (picker === 'year') {
+ (['none', 'capture', 'bubble'] as const).forEach(phase => {
+ it(
+ phase === 'none'
+ ? 'navigates after custom year content keydown'
+ : `honors custom year content ${phase} cancellation`,
+ () => {
+ const onKeyDownCapture = cy.stub().callsFake((event: React.KeyboardEvent) => {
+ if (phase === 'capture') {
+ event.preventDefault();
+ }
+ });
+ const onKeyDown = cy.stub().callsFake((event: React.KeyboardEvent) => {
+ if (phase === 'bubble') {
+ event.preventDefault();
+ }
+ });
+ mountFluent(
+ (
+
+ {year}
+
+ ),
+ },
+ }}
+ />,
+ );
+ openYearPicker();
+ cy.get('[title="custom-year-2020"]').focus().realPress('ArrowRight');
+ if (phase === 'none') {
+ cy.get('[title="custom-year-2021"]').closest('[role="gridcell"]').should('be.focused');
+ } else {
+ cy.get('[title="custom-year-2020"]').should('be.focused');
+ }
+ cy.wrap(onKeyDownCapture).should('have.been.calledOnce');
+ cy.wrap(onKeyDown).should('have.been.calledOnce');
+ },
+ );
+ });
+ }
+ });
+
it('keeps the month highlight on the navigated month', () => {
mountFluent( );
diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx
new file mode 100644
index 00000000000000..5e7e3131cde283
--- /dev/null
+++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx
@@ -0,0 +1,272 @@
+import * as React from 'react';
+import { act, fireEvent, render } from '@testing-library/react';
+import { slot, useMergedRefs } from '@fluentui/react-utilities';
+import type { ForwardRefComponent } from '@fluentui/react-utilities';
+import { Calendar } from './Calendar';
+import { useCalendarBase_unstable } from './useCalendar';
+import { useCalendarContextValues_unstable } from './useCalendarContextValues';
+import { renderCalendar_unstable } from './renderCalendar';
+import { useCalendarDay_unstable } from '../CalendarDay/useCalendarDay';
+import { useCalendarDayContextValues_unstable } from '../CalendarDay/useCalendarDayContextValues';
+import { renderCalendarDay_unstable } from '../CalendarDay/renderCalendarDay';
+import { useCalendarMonth_unstable } from '../CalendarMonth/useCalendarMonth';
+import { useCalendarMonthContextValues_unstable } from '../CalendarMonth/useCalendarMonthContextValues';
+import { renderCalendarMonth_unstable } from '../CalendarMonth/renderCalendarMonth';
+import type { CalendarProps } from './Calendar.types';
+import type { CalendarDay } from '../CalendarDay/CalendarDay';
+import type { CalendarDayHandle, CalendarDayProps } from '../CalendarDay/CalendarDay.types';
+import type { CalendarMonth } from '../CalendarMonth/CalendarMonth';
+import type { CalendarMonthHandle, CalendarMonthProps } from '../CalendarMonth/CalendarMonth.types';
+
+const ClasslessCalendarDay: typeof CalendarDay = React.forwardRef((props, ref) => {
+ const state = useCalendarDay_unstable(props, ref);
+ return renderCalendarDay_unstable(state, useCalendarDayContextValues_unstable(state));
+});
+
+const ClasslessCalendarMonth: typeof CalendarMonth = React.forwardRef(
+ (props, ref) => {
+ const state = useCalendarMonth_unstable(props, ref);
+ return renderCalendarMonth_unstable(state, useCalendarMonthContextValues_unstable(state));
+ },
+);
+
+const ClasslessCalendar: ForwardRefComponent = React.forwardRef((props, ref) => {
+ const state = useCalendarBase_unstable(props, ref);
+ state.dayPicker = slot.optional(props.dayPicker, {
+ renderByDefault: true,
+ defaultProps: state.dayPicker,
+ elementType: ClasslessCalendarDay,
+ });
+ state.monthPicker = slot.optional(props.monthPicker, {
+ renderByDefault: true,
+ defaultProps: state.monthPicker,
+ elementType: ClasslessCalendarMonth,
+ });
+ const dayPickerRef = useMergedRefs(state.dayPickerRef, state.dayPicker?.ref);
+ const monthPickerRef = useMergedRefs(state.monthPickerRef, state.monthPicker?.ref);
+ if (state.dayPicker) {
+ state.dayPicker.ref = dayPickerRef;
+ }
+ if (state.monthPicker) {
+ state.monthPicker.ref = monthPickerRef;
+ }
+
+ return renderCalendar_unstable(
+ {
+ ...state,
+ components: {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ ...state.components,
+ dayPicker: ClasslessCalendarDay,
+ monthPicker: ClasslessCalendarMonth,
+ },
+ },
+ useCalendarContextValues_unstable(state),
+ );
+});
+
+const today = new Date(2020, 8, 18);
+
+function mockResponsiveLayout(container: HTMLElement) {
+ const targetWindow = container.ownerDocument.defaultView!;
+ const descriptor = Object.getOwnPropertyDescriptor(targetWindow, 'matchMedia');
+ const mediaQuery = {
+ matches: false,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ };
+ Object.defineProperty(targetWindow, 'matchMedia', {
+ configurable: true,
+ value: jest.fn((query: string) =>
+ query === '(max-width: 440px)'
+ ? mediaQuery
+ : { matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() },
+ ),
+ });
+
+ return {
+ narrow() {
+ mediaQuery.matches = true;
+ const [, onChange] = mediaQuery.addEventListener.mock.calls[0];
+ act(() => onChange());
+ },
+ restore() {
+ if (descriptor) {
+ Object.defineProperty(targetWindow, 'matchMedia', descriptor);
+ } else {
+ Reflect.deleteProperty(targetWindow, 'matchMedia');
+ }
+ },
+ };
+}
+
+describe.each([
+ { name: 'styled Calendar', Component: Calendar },
+ { name: 'classless base composition', Component: ClasslessCalendar },
+])('$name focus ownership', ({ name, Component }) => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => {
+ act(() => jest.runOnlyPendingTimers());
+ jest.useRealTimers();
+ });
+
+ it('reserves behavioral picker markers while preserving consumer classes', () => {
+ const dayPicker = { 'data-fui-calendar-picker': 'month', className: 'custom-day' };
+ const monthPicker = { 'data-fui-calendar-picker': 'day', className: 'custom-month' };
+ const { getByRole } = render(
+ ,
+ );
+
+ const dayRoot = getByRole('button', { name: 'September 18, 2020' }).closest('[data-fui-calendar-picker]');
+ const monthRoot = getByRole('gridcell', { name: 'March' }).closest('[data-fui-calendar-picker]');
+ expect(dayRoot).toHaveAttribute('data-fui-calendar-picker', 'day');
+ expect(monthRoot).toHaveAttribute('data-fui-calendar-picker', 'month');
+ expect(dayRoot).toHaveClass('custom-day');
+ expect(monthRoot).toHaveClass('custom-month');
+ });
+
+ it.each(['month', 'year'] as const)(
+ 'restores day focus when responsive overlay hides the focused %s picker and preserves consumer handlers',
+ picker => {
+ const onFocusCapture = jest.fn();
+ const onDayFocusCapture = jest.fn();
+ const onMonthFocusCapture = jest.fn();
+ const { container, getByRole, queryByRole, rerender, unmount } = render(<>>);
+ const media = mockResponsiveLayout(container);
+
+ try {
+ rerender(
+ ,
+ );
+ if (name === 'classless base composition') {
+ expect(container.querySelector('.fui-Calendar, .fui-CalendarDay, .fui-CalendarMonth')).toBeNull();
+ }
+
+ if (picker === 'year') {
+ fireEvent.click(getByRole('button', { name: '2020, change year' }));
+ }
+ const focusedCell = getByRole('gridcell', { name: picker === 'month' ? 'March' : '2020' });
+ act(() => focusedCell.focus());
+ expect(focusedCell).toHaveFocus();
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
+ expect(onMonthFocusCapture).toHaveBeenCalledTimes(1);
+
+ media.narrow();
+ act(() => jest.advanceTimersToNextFrame());
+
+ expect(queryByRole('gridcell', { name: picker === 'month' ? 'March' : '2020' })).toBeNull();
+ expect(getByRole('button', { name: 'September 18, 2020' }).closest('td')).toHaveFocus();
+ expect(onFocusCapture).toHaveBeenCalledTimes(2);
+ expect(onDayFocusCapture).toHaveBeenCalledTimes(1);
+ expect(onMonthFocusCapture).toHaveBeenCalledTimes(1);
+ } finally {
+ unmount();
+ media.restore();
+ }
+ },
+ );
+
+ it.each(['day', 'month'] as const)('restores focus when a controlled view hides the focused %s picker', view => {
+ const { getByRole, rerender } = render(
+ ,
+ );
+ const focusedCell =
+ view === 'day'
+ ? getByRole('button', { name: 'September 18, 2020' }).closest('td')!
+ : getByRole('gridcell', { name: 'March' });
+ act(() => focusedCell.focus());
+
+ rerender( );
+ act(() => jest.advanceTimersToNextFrame());
+
+ const restoredCell =
+ view === 'day'
+ ? getByRole('gridcell', { name: 'September' })
+ : getByRole('button', { name: 'September 18, 2020' }).closest('td');
+ expect(restoredCell).toHaveFocus();
+ });
+
+ it('does not reclaim outside focus when responsive overlay hides the previously focused month picker', () => {
+ const onBlurCapture = jest.fn();
+ const { container, getByRole, rerender, unmount } = render(<>>);
+ const media = mockResponsiveLayout(container);
+
+ try {
+ rerender(
+ <>
+
+ Outside action
+ >,
+ );
+ act(() => getByRole('gridcell', { name: 'March' }).focus());
+ const outside = getByRole('button', { name: 'Outside action' });
+ act(() => outside.focus());
+ expect(onBlurCapture).toHaveBeenCalledTimes(1);
+
+ media.narrow();
+ act(() => jest.advanceTimersToNextFrame());
+
+ expect(outside).toHaveFocus();
+ } finally {
+ unmount();
+ media.restore();
+ }
+ });
+
+ it('does not classify an enclosing picker as part of this Calendar', () => {
+ const { container, getByTestId, rerender, unmount } = render(<>>);
+ const media = mockResponsiveLayout(container);
+
+ try {
+ rerender(
+
+
+
,
+ );
+ const root = getByTestId('calendar');
+ act(() => root.focus());
+
+ media.narrow();
+ act(() => jest.advanceTimersToNextFrame());
+
+ expect(root).toHaveFocus();
+ } finally {
+ unmount();
+ media.restore();
+ }
+ });
+
+ it('cancels pending restoration when focus moves outside after the focused picker is removed', () => {
+ const { getByRole, rerender } = render(
+ <>
+
+ Outside action
+ >,
+ );
+ act(() => getByRole('gridcell', { name: 'March' }).focus());
+ rerender(
+ <>
+
+ Outside action
+ >,
+ );
+ const outside = getByRole('button', { name: 'Outside action' });
+ act(() => outside.focus());
+ act(() => jest.advanceTimersToNextFrame());
+
+ expect(outside).toHaveFocus();
+ });
+});
diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx
new file mode 100644
index 00000000000000..cba0d63bfee4bb
--- /dev/null
+++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx
@@ -0,0 +1,950 @@
+import * as React from 'react';
+import { flushSync } from 'react-dom';
+import { act, fireEvent, render } from '@testing-library/react';
+import { Provider_unstable } from '@fluentui/react-shared-contexts';
+import { CalendarProvider, calendarContextDefaultValue } from '../../contexts/calendarContext';
+import { useCalendarDayContext_unstable } from '../../contexts/calendarDayContext';
+import { useCalendarMonthContext_unstable } from '../../contexts/calendarMonthContext';
+import { useCalendarYearContext_unstable } from '../../contexts/calendarYearContext';
+import { CalendarDay } from '../CalendarDay/CalendarDay';
+import { useCalendarDayBase_unstable } from '../CalendarDay/useCalendarDay';
+import { renderCalendarDay_unstable } from '../CalendarDay/renderCalendarDay';
+import { useCalendarDayContextValues_unstable } from '../CalendarDay/useCalendarDayContextValues';
+import { CalendarDayGridCell } from '../CalendarDayGridCell/CalendarDayGridCell';
+import { useCalendarDayGridCell_unstable } from '../CalendarDayGridCell/useCalendarDayGridCell';
+import { renderCalendarDayGridCell_unstable } from '../CalendarDayGridCell/renderCalendarDayGridCell';
+import { CalendarMonth } from '../CalendarMonth/CalendarMonth';
+import { useCalendarMonthBase_unstable } from '../CalendarMonth/useCalendarMonth';
+import { renderCalendarMonth_unstable } from '../CalendarMonth/renderCalendarMonth';
+import { useCalendarMonthContextValues_unstable } from '../CalendarMonth/useCalendarMonthContextValues';
+import { CalendarYear } from '../CalendarYear/CalendarYear';
+import { useCalendarYearBase_unstable } from '../CalendarYear/useCalendarYear';
+import { renderCalendarYear_unstable } from '../CalendarYear/renderCalendarYear';
+import { useCalendarYearContextValues_unstable } from '../CalendarYear/useCalendarYearContextValues';
+import { CalendarMonthGridCell } from '../CalendarMonthGridCell/CalendarMonthGridCell';
+import { useCalendarMonthGridCell_unstable } from '../CalendarMonthGridCell/useCalendarMonthGridCell';
+import { renderCalendarMonthGridCell_unstable } from '../CalendarMonthGridCell/renderCalendarMonthGridCell';
+import { CalendarYearGridCell } from '../CalendarYearGridCell/CalendarYearGridCell';
+import { useCalendarYearGridCell_unstable } from '../CalendarYearGridCell/useCalendarYearGridCell';
+import { renderCalendarYearGridCell_unstable } from '../CalendarYearGridCell/renderCalendarYearGridCell';
+import { handleCalendarGridKeyDown, isCalendarKeyboardEditable } from '../../utils/calendarKeyboard';
+import type { CalendarMonthHandle, CalendarMonthProps } from '../CalendarMonth/CalendarMonth.types';
+import type { CalendarDayHandle, CalendarDayProps } from '../CalendarDay/CalendarDay.types';
+import type { CalendarDayGridCellProps } from '../CalendarDayGridCell/CalendarDayGridCell.types';
+import type { CalendarYearHandle, CalendarYearProps } from '../CalendarYear/CalendarYear.types';
+import type { CalendarMonthGridCellProps } from '../CalendarMonthGridCell/CalendarMonthGridCell.types';
+import type { CalendarYearGridCellProps } from '../CalendarYearGridCell/CalendarYearGridCell.types';
+
+const BaseDay: typeof CalendarDay = React.forwardRef((props, ref) => {
+ const state = useCalendarDayBase_unstable(props, ref);
+ return renderCalendarDay_unstable(state, useCalendarDayContextValues_unstable(state));
+});
+
+const BaseDayCell: typeof CalendarDayGridCell = React.forwardRef(
+ (props, ref) => renderCalendarDayGridCell_unstable(useCalendarDayGridCell_unstable(props, ref)),
+);
+
+const BaseMonth: typeof CalendarMonth = React.forwardRef((props, ref) => {
+ const state = useCalendarMonthBase_unstable(props, ref);
+ return renderCalendarMonth_unstable(state, useCalendarMonthContextValues_unstable(state));
+});
+
+const BaseYear: typeof CalendarYear = React.forwardRef((props, ref) => {
+ const state = useCalendarYearBase_unstable(props, ref);
+ return renderCalendarYear_unstable(state, useCalendarYearContextValues_unstable(state));
+});
+
+const BaseMonthCell: typeof CalendarMonthGridCell = React.forwardRef(
+ (props, ref) => renderCalendarMonthGridCell_unstable(useCalendarMonthGridCell_unstable(props, ref)),
+);
+
+const BaseYearCell: typeof CalendarYearGridCell = React.forwardRef(
+ (props, ref) => renderCalendarYearGridCell_unstable(useCalendarYearGridCell_unstable(props, ref)),
+);
+
+type RowProps = {
+ base: boolean;
+ disabledIndices?: number[];
+ onCellKeyDown?: React.KeyboardEventHandler;
+ onCellKeyDownCapture?: React.KeyboardEventHandler;
+};
+
+const DayRows = ({ base }: RowProps) => {
+ const weeks = useCalendarDayContext_unstable(ctx => ctx.weeks);
+ const Cell = base ? BaseDayCell : CalendarDayGridCell;
+ return (
+ <>
+ {weeks.slice(1, -1).map((week, weekIndex) => (
+
+ {week.map((day, dayIndex) => (
+ |
+ ))}
+
+ ))}
+ >
+ );
+};
+
+const MonthRows = ({ base, disabledIndices = [], onCellKeyDown, onCellKeyDownCapture }: RowProps) => {
+ const rows = useCalendarMonthContext_unstable(ctx => ctx.monthRows);
+ const Cell = base ? BaseMonthCell : CalendarMonthGridCell;
+ return (
+ <>
+ {rows.map((months, row) => (
+
+ {months.map(month => (
+ |
+ ))}
+
+ ))}
+ >
+ );
+};
+
+const YearRows = ({ base, disabledIndices = [], onCellKeyDown, onCellKeyDownCapture }: RowProps) => {
+ const rows = useCalendarYearContext_unstable(ctx => ctx.yearRows);
+ const Cell = base ? BaseYearCell : CalendarYearGridCell;
+ return (
+ <>
+ {rows.map((years, row) => (
+
+ {years.map((cell, column) => (
+ |
+ ))}
+
+ ))}
+ >
+ );
+};
+
+type FixtureProps = Omit & {
+ allFocusable?: boolean;
+ dir?: 'ltr' | 'rtl';
+ onGridKeyDownCapture?: React.KeyboardEventHandler;
+ onButtonKeyDown?: React.KeyboardEventHandler;
+ onButtonKeyDownCapture?: React.KeyboardEventHandler;
+ dayLabel?: CalendarDayGridCellProps['dayLabel'];
+ marker?: CalendarDayGridCellProps['marker'];
+};
+
+function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: FixtureProps = {}) {
+ const Month = base ? BaseMonth : CalendarMonth;
+ const Year = base ? BaseYear : CalendarYear;
+ const Day = base ? BaseDay : CalendarDay;
+ const onSelect = jest.fn();
+ const onNavigate = jest.fn();
+ const today = new Date(2020, 8, 18);
+ const element = (fixtureProps: FixtureProps) => {
+ const {
+ allFocusable = false,
+ dir = 'ltr',
+ onGridKeyDownCapture,
+ onButtonKeyDown,
+ onButtonKeyDownCapture,
+ dayLabel,
+ marker,
+ ...rowProps
+ } = fixtureProps;
+ return (
+
+
+ {picker === 'day' ? (
+ ({
+ onKeyDown: rowProps.onCellKeyDown,
+ onKeyDownCapture: rowProps.onCellKeyDownCapture,
+ button: {
+ onKeyDown: onButtonKeyDown,
+ onKeyDownCapture: onButtonKeyDownCapture,
+ },
+ dayLabel,
+ marker,
+ })}
+ grid={{ onKeyDownCapture: onGridKeyDownCapture }}
+ body={{ children: }}
+ />
+ ) : picker === 'month' ? (
+ ,
+ }}
+ />
+ ) : (
+ ,
+ }}
+ />
+ )}
+
+
+ );
+ };
+ const result = render(element(props));
+ return {
+ ...result,
+ cells: result.getAllByRole('gridcell'),
+ onSelect,
+ onNavigate,
+ rerenderPicker: (nextProps: FixtureProps) => result.rerender(element(nextProps)),
+ };
+}
+
+function appendFocusgroup(cell: HTMLElement, focusgroup: string) {
+ const targetDocument = cell.ownerDocument;
+ const group = targetDocument.createElement('span');
+ group.setAttribute('role', 'toolbar');
+ group.setAttribute('focusgroup', focusgroup);
+ const first = targetDocument.createElement('span');
+ first.tabIndex = 0;
+ first.textContent = 'First';
+ const second = targetDocument.createElement('span');
+ second.tabIndex = 0;
+ second.textContent = 'Second';
+ group.append(first, second);
+ (cell.querySelector('button') ?? cell).appendChild(group);
+ return { group, first, second };
+}
+
+describe.each([
+ { name: 'base month', picker: 'month', base: true },
+ { name: 'base year', picker: 'year', base: true },
+ { name: 'styled month', picker: 'month', base: false },
+ { name: 'styled year', picker: 'year', base: false },
+] as const)('$name grid keyboard navigation', ({ picker, base }) => {
+ it('preserves grid semantics and delegates only roving tab stops to the styled layer', () => {
+ const { container, getByRole, getAllByRole, cells } = renderPicker(picker, base);
+ expect(getAllByRole('row')).toHaveLength(3);
+ expect(cells).toHaveLength(12);
+ if (base) {
+ expect(
+ container.querySelector(
+ '.fui-CalendarMonth, .fui-CalendarYear, .fui-CalendarMonthGridCell, .fui-CalendarYearGridCell',
+ ),
+ ).toBeNull();
+ expect(getByRole('grid')).not.toHaveAttribute('data-tabster');
+ } else {
+ const attributes = JSON.parse(getByRole('grid').getAttribute('data-tabster')!);
+ expect(attributes.focusable.ignoreKeydown).toEqual({
+ ArrowDown: true,
+ ArrowLeft: true,
+ ArrowRight: true,
+ ArrowUp: true,
+ });
+ }
+ });
+
+ describe.each(['day', 'month', 'year'] as const)('%s grid keyboard editable boundaries', pickerName => {
+ it('allows navigation within a contenteditable=false island inside an editable ancestor', () => {
+ const { cells, container, getByRole } = renderPicker(pickerName, false);
+ const grid = getByRole('grid');
+ const targetDocument = grid.ownerDocument;
+ const editor = targetDocument.createElement('div');
+ const island = targetDocument.createElement('div');
+ editor.setAttribute('contenteditable', 'true');
+ island.setAttribute('contenteditable', 'false');
+ container.appendChild(editor);
+ editor.appendChild(island);
+ island.appendChild(grid);
+
+ act(() => cells[0].focus());
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[1]).toHaveFocus();
+ });
+ });
+
+ it.each(['inherit', 'FALSE'])('treats contenteditable=%s as non-editable', value => {
+ const { cells } = renderPicker(picker, base);
+ const target = cells[0].ownerDocument.createElement('span');
+ target.setAttribute('contenteditable', value);
+ cells[0].appendChild(target);
+ act(() => target.focus());
+ fireEvent.keyDown(target, { key: 'ArrowRight' });
+
+ expect(cells[1]).toHaveFocus();
+ expect(isCalendarKeyboardEditable(target)).toBe(false);
+ });
+
+ it.each([
+ { key: 'ArrowRight', target: 6 },
+ { key: 'ArrowLeft', target: 4 },
+ { key: 'ArrowDown', target: 9 },
+ { key: 'ArrowUp', target: 1 },
+ ])('moves focus with $key without selecting or navigating the picker', ({ key, target }) => {
+ const { cells, onSelect, onNavigate } = renderPicker(picker, base);
+ act(() => cells[5].focus());
+
+ const defaultAllowed = fireEvent.keyDown(cells[5], { key });
+ expect(cells[target]).toHaveFocus();
+ expect(defaultAllowed).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it('continues past a candidate that cannot receive focus, including to tabIndex=-1 cells', () => {
+ const { cells } = renderPicker(picker, base);
+ cells[1].hidden = true;
+ const failedFocus = jest.spyOn(cells[1], 'focus').mockImplementation(() => undefined);
+ cells[2].tabIndex = -1;
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(failedFocus).toHaveBeenCalledTimes(1);
+ expect(cells[2]).toHaveFocus();
+ failedFocus.mockRestore();
+ });
+
+ it.each([
+ { key: 'ArrowRight', targetIndex: 1 },
+ { key: 'ArrowDown', targetIndex: 4 },
+ ])('uses the shadow root activeElement after $key', ({ key, targetIndex }) => {
+ const host = document.createElement('div');
+ const shadowRoot = host.attachShadow({ mode: 'open' });
+ const grid = document.createElement('div');
+ grid.setAttribute('role', 'grid');
+ const shadowCells = Array.from({ length: 12 }, () => {
+ const cell = document.createElement('button');
+ cell.setAttribute('role', 'gridcell');
+ return cell;
+ });
+ grid.append(...shadowCells);
+ shadowRoot.appendChild(grid);
+ document.body.appendChild(host);
+ act(() => shadowCells[0].focus());
+ const event = {
+ currentTarget: shadowCells[0],
+ isDefaultPrevented: () => false,
+ key,
+ shiftKey: false,
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ target: shadowCells[0],
+ preventDefault: jest.fn(),
+ } as unknown as React.KeyboardEvent;
+
+ handleCalendarGridKeyDown(event, 'ltr', false);
+
+ expect(shadowRoot.activeElement).toBe(shadowCells[targetIndex]);
+ host.remove();
+ });
+
+ it.each(['inherit', 'FALSE'])('does not consider contenteditable=%s editable without an editable ancestor', value => {
+ const target = document.createElement('span');
+ target.setAttribute('contenteditable', value);
+ expect(isCalendarKeyboardEditable(target)).toBe(false);
+ });
+
+ it.each([
+ { key: 'ArrowRight', target: 4 },
+ { key: 'ArrowLeft', target: 6 },
+ ])('respects RTL for $key', ({ key, target }) => {
+ const { cells } = renderPicker(picker, base, { dir: 'rtl' });
+ act(() => cells[5].focus());
+
+ expect(fireEvent.keyDown(cells[5], { key })).toBe(false);
+ expect(cells[target]).toHaveFocus();
+ });
+
+ it.each([
+ { key: 'ArrowRight', index: 3 },
+ { key: 'ArrowLeft', index: 4 },
+ { key: 'ArrowUp', index: 1 },
+ { key: 'ArrowDown', index: 9 },
+ ])('prevents fallback movement at the $key boundary', ({ key, index }) => {
+ const { cells } = renderPicker(picker, base);
+ act(() => cells[index].focus());
+
+ expect(fireEvent.keyDown(cells[index], { key })).toBe(false);
+ expect(cells[index]).toHaveFocus();
+ });
+
+ it.each([
+ { key: 'ArrowRight', target: 2 },
+ { key: 'ArrowDown', target: 8 },
+ ])('skips unavailable cells with $key without changing row or column', ({ key, target }) => {
+ const { cells } = renderPicker(picker, base, { disabledIndices: [1, 4] });
+ act(() => cells[0].focus());
+
+ expect(fireEvent.keyDown(cells[0], { key })).toBe(false);
+ expect(cells[target]).toHaveFocus();
+ });
+
+ it('keeps an unavailable cell navigable but not selectable with allFocusable', () => {
+ const { cells, onSelect } = renderPicker(picker, base, { allFocusable: true, disabledIndices: [1] });
+ act(() => cells[0].focus());
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[1]).toHaveFocus();
+ expect(cells[1]).toHaveAttribute('aria-disabled', 'true');
+ expect(cells[1]).not.toBeDisabled();
+ fireEvent.keyDown(cells[1], { key: 'Enter' });
+ expect(onSelect).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(cells[1], { key: 'ArrowDown' });
+ expect(cells[5]).toHaveFocus();
+ });
+
+ it.each(['ArrowRight', 'Enter'])('honors consumer cancellation of %s', key => {
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const { cells, onSelect, onNavigate } = renderPicker(picker, base, { onCellKeyDown });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key });
+
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(cells[0]).toHaveFocus();
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it('honors cancellation from the grid capture handler', () => {
+ const onGridKeyDownCapture = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const { cells } = renderPicker(picker, base, { onGridKeyDownCapture });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowDown' });
+
+ expect(onGridKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(cells[0]).toHaveFocus();
+ });
+
+ it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])('preserves modified arrows with %s', modifier => {
+ const { cells } = renderPicker(picker, base);
+ act(() => cells[0].focus());
+
+ expect(fireEvent.keyDown(cells[0], { key: 'ArrowRight', [modifier]: true })).toBe(true);
+ expect(cells[0]).toHaveFocus();
+ });
+
+ it.each(['input', 'textarea', 'select', 'contenteditable'])('preserves arrows in nested %s content', tag => {
+ const { cells, onSelect } = renderPicker(picker, base);
+ const targetDocument = cells[0].ownerDocument;
+ const editable = targetDocument.createElement(tag === 'contenteditable' ? 'div' : tag);
+ const target = tag === 'contenteditable' ? targetDocument.createElement('span') : editable;
+ if (tag === 'contenteditable') {
+ editable.setAttribute('contenteditable', 'true');
+ editable.tabIndex = 0;
+ editable.appendChild(target);
+ }
+ cells[0].appendChild(editable);
+ act(() => editable.focus());
+
+ for (const key of ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Enter']) {
+ expect(fireEvent.keyDown(target, { key })).toBe(true);
+ expect(editable).toHaveFocus();
+ }
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('can focus cells removed from the tab order by a roving-focus layer', () => {
+ const { cells } = renderPicker(picker, base);
+ cells.forEach((cell, index) => {
+ cell.tabIndex = index === 0 ? 0 : -1;
+ });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowDown' });
+
+ expect(cells[4]).toHaveFocus();
+ });
+
+ it('does not take over an independently nested grid', () => {
+ const { cells } = renderPicker(picker, base);
+ const targetDocument = cells[0].ownerDocument;
+ const nestedGrid = targetDocument.createElement('div');
+ const nestedCell = targetDocument.createElement('button');
+ nestedGrid.setAttribute('role', 'grid');
+ nestedCell.setAttribute('role', 'gridcell');
+ nestedGrid.appendChild(nestedCell);
+ cells[0].appendChild(nestedGrid);
+ act(() => nestedCell.focus());
+
+ expect(fireEvent.keyDown(nestedCell, { key: 'ArrowRight' })).toBe(true);
+ expect(nestedCell).toHaveFocus();
+ });
+});
+
+describe.each([
+ { name: 'base day', picker: 'day', base: true },
+ { name: 'base month', picker: 'month', base: true },
+ { name: 'base year', picker: 'year', base: true },
+ { name: 'styled day', picker: 'day', base: false },
+ { name: 'styled month', picker: 'month', base: false },
+ { name: 'styled year', picker: 'year', base: false },
+] as const)('$name native keyboard ordering', ({ picker, base }) => {
+ if (picker === 'day') {
+ it('does not invoke child button handlers for a cell-targeted event', () => {
+ const onButtonKeyDownCapture = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const onButtonKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const { cells } = renderPicker(picker, base, { onButtonKeyDown, onButtonKeyDownCapture });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[1]).toHaveFocus();
+ expect(onButtonKeyDownCapture).not.toHaveBeenCalled();
+ expect(onButtonKeyDown).not.toHaveBeenCalled();
+ });
+
+ it.each(['dayLabel', 'marker'] as const)('preserves %s capture and bubble event ordering', slotName => {
+ const order: string[] = [];
+ const onButtonKeyDownCapture = jest.fn(() => order.push('button capture'));
+ const onButtonKeyDown = jest.fn(() => order.push('button bubble'));
+ const capture = jest.fn((event: React.KeyboardEvent) => {
+ order.push('slot capture');
+ expect(event.currentTarget).toBe(target);
+ event.preventDefault();
+ });
+ const bubble = jest.fn((event: React.KeyboardEvent) => {
+ order.push('slot bubble');
+ expect(event.currentTarget).toBe(target);
+ });
+ const { cells, getAllByTitle } = renderPicker(picker, base, {
+ onButtonKeyDown,
+ onButtonKeyDownCapture,
+ [slotName]: { tabIndex: 0, title: 'target', onKeyDownCapture: capture, onKeyDown: bubble },
+ });
+ const target = getAllByTitle('target')[0];
+ act(() => target.focus());
+
+ fireEvent.keyDown(target, { key: 'ArrowRight' });
+
+ expect(target).toHaveFocus();
+ expect(order).toEqual(['button capture', 'slot capture', 'slot bubble', 'button bubble']);
+ expect(capture).toHaveBeenCalledTimes(1);
+ expect(bubble).toHaveBeenCalledTimes(1);
+ expect(cells[1]).not.toHaveFocus();
+ });
+
+ it('honors dayLabel bubbling cancellation before navigation', () => {
+ const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const { getAllByTitle } = renderPicker(picker, base, {
+ dayLabel: { tabIndex: 0, title: 'label', onKeyDown },
+ });
+ const label = getAllByTitle('label')[0];
+ act(() => label.focus());
+
+ fireEvent.keyDown(label, { key: 'ArrowRight' });
+
+ expect(label).toHaveFocus();
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps navigation when the cell bubbling handler only stops propagation', () => {
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => event.stopPropagation());
+ const { cells } = renderPicker(picker, base, { onCellKeyDown });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[1]).toHaveFocus();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops later consumer handlers after slot capture stops propagation', () => {
+ const onButtonKeyDown = jest.fn();
+ const onCellKeyDown = jest.fn();
+ const { getAllByTitle } = renderPicker(picker, base, {
+ onButtonKeyDown,
+ onCellKeyDown,
+ dayLabel: {
+ tabIndex: 0,
+ title: 'label',
+ onKeyDownCapture: event => {
+ event.stopPropagation();
+ },
+ },
+ });
+ const label = getAllByTitle('label')[0];
+ act(() => label.focus());
+ fireEvent.keyDown(label, { key: 'ArrowRight' });
+ expect(label).toHaveFocus();
+ expect(onButtonKeyDown).not.toHaveBeenCalled();
+ expect(onCellKeyDown).not.toHaveBeenCalled();
+ });
+
+ it.each(['capture', 'bubble'] as const)('honors button %s cancellation before day navigation', phase => {
+ const onButtonKeyDownCapture = jest.fn((event: React.KeyboardEvent) => {
+ if (phase === 'capture') {
+ event.preventDefault();
+ }
+ });
+ const onButtonKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ if (phase === 'bubble') {
+ event.preventDefault();
+ }
+ });
+ const { cells, onNavigate } = renderPicker(picker, base, {
+ onButtonKeyDown,
+ onButtonKeyDownCapture,
+ });
+ const button = cells[0].querySelector('button')!;
+ act(() => button.focus());
+
+ fireEvent.keyDown(button, { key: 'ArrowRight' });
+
+ expect(button).toHaveFocus();
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(onButtonKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(onButtonKeyDown).toHaveBeenCalledTimes(1);
+ expect(onButtonKeyDownCapture.mock.calls[0][0].currentTarget).toBe(button);
+ if (phase === 'bubble') {
+ expect(onButtonKeyDown.mock.calls[0][0].currentTarget).toBe(button);
+ }
+ });
+ }
+
+ it('preserves capture cancellation and calls the consumer keydown once', () => {
+ const onCellKeyDownCapture = jest.fn((event: React.KeyboardEvent) => event.preventDefault());
+ const onCellKeyDown = jest.fn();
+ const { cells } = renderPicker(picker, base, { onCellKeyDown, onCellKeyDownCapture });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[0]).toHaveFocus();
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves propagation stopped by the consumer capture handler', () => {
+ const onCellKeyDownCapture = jest.fn((event: React.KeyboardEvent) => event.stopPropagation());
+ const onCellKeyDown = jest.fn();
+ const { cells } = renderPicker(picker, base, { onCellKeyDown, onCellKeyDownCapture });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(cells[0]).toHaveFocus();
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDown).not.toHaveBeenCalled();
+ });
+
+ it('does not dispatch an arrow again after a synchronous consumer rerender', () => {
+ const onAfterRerender = jest.fn();
+ const onCellKeyDown = jest.fn(() => {
+ flushSync(() => rerenderPicker({ onCellKeyDown: onAfterRerender }));
+ });
+ const { cells, getAllByRole, rerenderPicker } = renderPicker(picker, base, { onCellKeyDown });
+ act(() => cells[0].focus());
+
+ fireEvent.keyDown(cells[0], { key: 'ArrowRight' });
+
+ expect(getAllByRole('gridcell')[0]).toBe(cells[0]);
+ expect(cells[1]).toHaveFocus();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(onAfterRerender).not.toHaveBeenCalled();
+ });
+
+ it('leaves editable arrows in bubbling without moving or selecting the enclosing cell', () => {
+ const phases: number[] = [];
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => phases.push(event.eventPhase));
+ const { cells, onSelect } = renderPicker(picker, base, { onCellKeyDown });
+ const input = cells[0].ownerDocument.createElement('input');
+ cells[0].appendChild(input);
+ act(() => input.focus());
+
+ expect(fireEvent.keyDown(input, { key: 'ArrowRight' })).toBe(true);
+
+ expect(input).toHaveFocus();
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(phases).toEqual([3]);
+ });
+
+ it('leaves a nested grid arrow in bubbling without taking over its focus', () => {
+ const phases: number[] = [];
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => phases.push(event.eventPhase));
+ const { cells } = renderPicker(picker, base, { onCellKeyDown });
+ const targetDocument = cells[0].ownerDocument;
+ const nestedGrid = targetDocument.createElement('div');
+ const nestedCell = targetDocument.createElement('button');
+ nestedGrid.setAttribute('role', 'grid');
+ nestedCell.setAttribute('role', 'gridcell');
+ nestedGrid.appendChild(nestedCell);
+ cells[0].appendChild(nestedGrid);
+ act(() => nestedCell.focus());
+
+ expect(fireEvent.keyDown(nestedCell, { key: 'ArrowRight' })).toBe(true);
+
+ expect(nestedCell).toHaveFocus();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(phases).toEqual([3]);
+ });
+
+ it.each(['toolbar inline', 'none'])(
+ 'leaves descendant focusgroup="%s" arrows in bubbling without Calendar navigation',
+ focusgroup => {
+ const phases: number[] = [];
+ const currentTargets: HTMLElement[] = [];
+ const nativeDefaults: boolean[] = [];
+ const onCellKeyDownCapture = jest.fn();
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ phases.push(event.eventPhase);
+ currentTargets.push(event.currentTarget);
+ });
+ const { cells, onSelect, onNavigate } = renderPicker(picker, base, {
+ onCellKeyDown,
+ onCellKeyDownCapture,
+ });
+ const { group, first } = appendFocusgroup(cells[0], focusgroup);
+ const nativeOwner = jest.fn((event: KeyboardEvent) => nativeDefaults.push(event.defaultPrevented));
+ group.addEventListener('keydown', nativeOwner);
+ const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'];
+
+ try {
+ act(() => first.focus());
+ for (const key of keys) {
+ expect(fireEvent.keyDown(first, { key })).toBe(true);
+ expect(first).toHaveFocus();
+ }
+
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(keys.length);
+ expect(onCellKeyDown).toHaveBeenCalledTimes(keys.length);
+ expect(nativeOwner).toHaveBeenCalledTimes(keys.length);
+ expect(nativeDefaults).toEqual(keys.map(() => false));
+ expect(phases).toEqual(keys.map(() => 3));
+ expect(currentTargets).toEqual(keys.map(() => cells[0]));
+ } finally {
+ group.removeEventListener('keydown', nativeOwner);
+ }
+ },
+ );
+
+ it('lets a nested native focusgroup move focus before the bubbling consumer', () => {
+ const order: string[] = [];
+ const currentTargets: HTMLElement[] = [];
+ const phases: number[] = [];
+ const onCellKeyDownCapture = jest.fn(() => order.push('capture'));
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ order.push('consumer');
+ currentTargets.push(event.currentTarget);
+ phases.push(event.eventPhase);
+ });
+ const { cells, onSelect, onNavigate } = renderPicker(picker, base, { onCellKeyDown, onCellKeyDownCapture });
+ const { group, first, second } = appendFocusgroup(cells[0], 'toolbar inline');
+ const nativeOwner = jest.fn((event: KeyboardEvent) => {
+ order.push('native');
+ if (!event.defaultPrevented) {
+ second.focus();
+ event.preventDefault();
+ }
+ });
+ group.addEventListener('keydown', nativeOwner);
+
+ try {
+ act(() => first.focus());
+ expect(fireEvent.keyDown(first, { key: 'ArrowRight' })).toBe(false);
+
+ expect(second).toHaveFocus();
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(nativeOwner).toHaveBeenCalledTimes(1);
+ expect(order).toEqual(['capture', 'native', 'consumer']);
+ expect(currentTargets).toEqual([cells[0]]);
+ expect(phases).toEqual([3]);
+ } finally {
+ group.removeEventListener('keydown', nativeOwner);
+ }
+ });
+
+ it.each([
+ { location: 'grid', focusgroup: 'toolbar inline' },
+ { location: 'grid', focusgroup: 'none' },
+ { location: 'ancestor', focusgroup: 'toolbar inline' },
+ { location: 'ancestor', focusgroup: 'none' },
+ ])('keeps Calendar arrows in capture with focusgroup="$focusgroup" on the $location', ({ location, focusgroup }) => {
+ const phases: number[] = [];
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => phases.push(event.eventPhase));
+ const { cells, container, getByRole, onSelect, onNavigate } = renderPicker(picker, base, {
+ onCellKeyDown,
+ });
+ const owner = location === 'grid' ? getByRole('grid') : container;
+ owner.setAttribute('focusgroup', focusgroup);
+ act(() => cells[0].focus());
+
+ expect(fireEvent.keyDown(cells[0], { key: 'ArrowRight' })).toBe(false);
+
+ expect(cells[1]).toHaveFocus();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(phases).toEqual([1]);
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it.each(['ArrowRight', 'ArrowDown'])('consumer cancellation precedes the native grid owner for %s', key => {
+ const order: string[] = [];
+ const consumerTargets: HTMLElement[] = [];
+ const captureTargets: HTMLElement[] = [];
+ const onCellKeyDownCapture = jest.fn((event: React.KeyboardEvent) => {
+ order.push('capture');
+ captureTargets.push(event.currentTarget);
+ });
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ order.push('consumer');
+ consumerTargets.push(event.currentTarget);
+ event.preventDefault();
+ });
+ const { cells, getByRole } = renderPicker(picker, base, { onCellKeyDown, onCellKeyDownCapture });
+ const owner = getByRole('grid');
+ const focusHops = jest.fn();
+ const nativeOwner = jest.fn((event: KeyboardEvent) => {
+ order.push('native');
+ if (!event.defaultPrevented) {
+ cells[1].focus();
+ event.preventDefault();
+ }
+ });
+ act(() => cells[0].focus());
+ owner.addEventListener('keydown', nativeOwner);
+ owner.addEventListener('focusin', focusHops);
+
+ try {
+ fireEvent.keyDown(cells[0], { key });
+
+ expect(cells[0]).toHaveFocus();
+ expect(focusHops).not.toHaveBeenCalled();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(nativeOwner).toHaveBeenCalledTimes(1);
+ expect(consumerTargets).toEqual([cells[0]]);
+ expect(captureTargets).toEqual([cells[0]]);
+ expect(order).toEqual(['capture', 'consumer', 'native']);
+ } finally {
+ owner.removeEventListener('keydown', nativeOwner);
+ owner.removeEventListener('focusin', focusHops);
+ }
+ });
+
+ it.each(['ArrowRight', 'ArrowDown'])('moves focus once before the native grid owner for %s', key => {
+ const phases: number[] = [];
+ const consumerTargets: HTMLElement[] = [];
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ phases.push(event.eventPhase);
+ consumerTargets.push(event.currentTarget);
+ });
+ const { cells, getByRole } = renderPicker(picker, base, { onCellKeyDown });
+ const owner = getByRole('grid');
+ const focusHops = jest.fn();
+ const nativeOwner = jest.fn((event: KeyboardEvent) => {
+ if (!event.defaultPrevented) {
+ cells[2].focus();
+ event.preventDefault();
+ }
+ });
+ act(() => cells[0].focus());
+ owner.addEventListener('keydown', nativeOwner);
+ owner.addEventListener('focusin', focusHops);
+
+ try {
+ fireEvent.keyDown(cells[0], { key });
+
+ expect(cells[key === 'ArrowRight' ? 1 : picker === 'day' ? 7 : 4]).toHaveFocus();
+ expect(focusHops).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(nativeOwner).toHaveBeenCalledTimes(1);
+ expect(consumerTargets).toEqual([cells[0]]);
+ expect(phases).toEqual([1]);
+ } finally {
+ owner.removeEventListener('keydown', nativeOwner);
+ owner.removeEventListener('focusin', focusHops);
+ }
+ });
+
+ it.each(['Enter', 'PageDown', 'Tab'])('keeps %s consumer handling in bubbling', key => {
+ const order: string[] = [];
+ const phases: number[] = [];
+ const onCellKeyDownCapture = jest.fn(() => order.push('capture'));
+ const onCellKeyDown = jest.fn((event: React.KeyboardEvent) => {
+ order.push('consumer');
+ phases.push(event.eventPhase);
+ });
+ const { cells, getByRole } = renderPicker(picker, base, { onCellKeyDown, onCellKeyDownCapture });
+ const owner = getByRole('grid');
+ const nativeOwner = () => order.push('native');
+ owner.addEventListener('keydown', nativeOwner);
+
+ try {
+ fireEvent.keyDown(cells[0], { key });
+
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDownCapture).toHaveBeenCalledTimes(1);
+ expect(order).toEqual(['capture', 'native', 'consumer']);
+ expect(phases).toEqual([3]);
+ } finally {
+ owner.removeEventListener('keydown', nativeOwner);
+ }
+ });
+
+ if (picker === 'day') {
+ it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])('leaves modified arrows in bubbling with %s', modifier => {
+ const onCellKeyDown = jest.fn();
+ const { cells, onNavigate } = renderPicker(picker, base, { onCellKeyDown });
+ act(() => cells[0].focus());
+
+ expect(fireEvent.keyDown(cells[0], { key: 'ArrowRight', [modifier]: true })).toBe(true);
+
+ expect(cells[0]).toHaveFocus();
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(onCellKeyDown).toHaveBeenCalledTimes(1);
+ expect(onCellKeyDown.mock.calls[0][0].defaultPrevented).toBe(false);
+ });
+
+ it('blocks native fallback while requesting controlled navigation beyond the visible day grid', () => {
+ const { cells, getByRole, onNavigate } = renderPicker(picker, base);
+ const owner = getByRole('grid');
+ const focusHops = jest.fn();
+ const nativeOwner = jest.fn((event: KeyboardEvent) => {
+ if (!event.defaultPrevented) {
+ cells[2].focus();
+ event.preventDefault();
+ }
+ });
+ act(() => cells[0].focus());
+ owner.addEventListener('keydown', nativeOwner);
+ owner.addEventListener('focusin', focusHops);
+
+ try {
+ fireEvent.keyDown(cells[0], { key: 'ArrowLeft' });
+
+ expect(cells[0]).toHaveFocus();
+ expect(focusHops).not.toHaveBeenCalled();
+ expect(nativeOwner).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate.mock.calls[0][1].date).toEqual(new Date(2020, 7, 29));
+ } finally {
+ owner.removeEventListener('keydown', nativeOwner);
+ owner.removeEventListener('focusin', focusHops);
+ }
+ });
+ }
+});
diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx
index b6784fd468565e..415af505c942b3 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx
+++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx
@@ -4,7 +4,6 @@ import * as React from 'react';
import { Enter, Escape, PageDown, PageUp } from '@fluentui/keyboard-keys';
import {
getIntrinsicElementProps,
- isHTMLElement,
slot,
useAnimationFrame,
useControllableState,
@@ -22,7 +21,7 @@ import {
} from '../../utils';
import { CalendarDay } from '../CalendarDay/CalendarDay';
import { CalendarMonth } from '../CalendarMonth/CalendarMonth';
-import { getCalendarNavigationDate } from '../../utils/calendarKeyboard';
+import { getCalendarNavigationDate, isCalendarKeyboardEditable } from '../../utils/calendarKeyboard';
import type { DayOfWeek } from '../../utils';
import type {
CalendarDayHandle,
@@ -375,10 +374,7 @@ export const useCalendarBase_unstable = (
case PageUp:
case PageDown: {
- if (
- isHTMLElement(ev.target) &&
- ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
- ) {
+ if (isCalendarKeyboardEditable(ev.target)) {
break;
}
const date = getCalendarNavigationDate(ev.nativeEvent) ?? navigatedDate;
@@ -398,14 +394,10 @@ export const useCalendarBase_unstable = (
isFocusWithin.current = true;
props.onFocusCapture?.(ev);
- const target = ev.target;
- if (target.closest('.fui-CalendarDay')) {
- focusedPicker.current = 'day';
- } else if (target.closest('.fui-CalendarMonth')) {
- focusedPicker.current = 'month';
- } else {
- focusedPicker.current = undefined;
- }
+ const picker = ev.target.closest('[data-fui-calendar-picker]');
+ const pickerType =
+ picker && ev.currentTarget.contains(picker) ? picker.getAttribute('data-fui-calendar-picker') : undefined;
+ focusedPicker.current = pickerType === 'day' || pickerType === 'month' ? pickerType : undefined;
});
const onRootBlurCapture = useEventCallback((ev: React.FocusEvent): void => {
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarDay/useCalendarDay.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarDay/useCalendarDay.tsx
index 2f60576e622044..80a3b78e556755 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarDay/useCalendarDay.tsx
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarDay/useCalendarDay.tsx
@@ -307,7 +307,9 @@ export const useCalendarDayBase_unstable = (
grid: 'table',
body: 'tbody',
},
- root: slot.always(getIntrinsicElementProps('div', props), { elementType: 'div' }),
+ root: slot.always(getIntrinsicElementProps('div', { ...props, 'data-fui-calendar-picker': 'day' }), {
+ elementType: 'div',
+ }),
header: slot.always(header, { elementType: 'div' }),
heading: slot.always(heading, {
defaultProps: {
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarDayGridCell/useCalendarDayGridCell.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarDayGridCell/useCalendarDayGridCell.ts
index 5bd611fc78a575..62565c28049346 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarDayGridCell/useCalendarDayGridCell.ts
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarDayGridCell/useCalendarDayGridCell.ts
@@ -1,18 +1,27 @@
'use client';
-import type * as React from 'react';
+import * as React from 'react';
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter, PageDown, PageUp, Space } from '@fluentui/keyboard-keys';
import {
getIntrinsicElementProps,
getRTLSafeKey,
+ isHTMLElement,
mergeCallbacks,
slot,
+ useEventCallback,
useMergedRefs,
} from '@fluentui/react-utilities';
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import { compareDatePart, findAvailableDate, stringifyDataAttribute } from '../../utils';
import { createDate } from '../../utils/dateMath';
-import { setCalendarNavigationDate } from '../../utils/calendarKeyboard';
+import {
+ getCalendarGridKeyDownHandlers,
+ invokeCalendarKeyboardHandler,
+ isCalendarArrowKey,
+ isCalendarGridArrow,
+ isCalendarKeyboardEditable,
+ setCalendarNavigationDate,
+} from '../../utils/calendarKeyboard';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
import { useCalendarDayContext_unstable } from '../../contexts/calendarDayContext';
import type { AvailableDateOptions } from '../../utils';
@@ -29,6 +38,41 @@ const NO_CORNERS: DayCorners = { topLeft: false, topRight: false, bottomLeft: fa
*/
const RANGE_HOVERED_ATTRIBUTE = 'data-range-hovered';
const RANGE_PRESSED_ATTRIBUTE = 'data-range-pressed';
+const dispatchedSlotElements = new WeakMap>();
+
+const wasSlotHandlerDispatched = (event: Event, element: EventTarget): boolean =>
+ dispatchedSlotElements.get(event)?.has(element) ?? false;
+
+const markSlotHandlerDispatched = (event: Event, element: EventTarget): void => {
+ let dispatchedElements = dispatchedSlotElements.get(event);
+ if (!dispatchedElements) {
+ dispatchedElements = new WeakSet();
+ dispatchedSlotElements.set(event, dispatchedElements);
+ }
+ dispatchedElements.add(element);
+};
+
+const forwardSlotKeyboardHandlers = (
+ slotProps: Pick, 'onKeyDown' | 'onKeyDownCapture'>,
+) => {
+ const { onKeyDown, onKeyDownCapture } = slotProps;
+ slotProps.onKeyDownCapture = event => {
+ if (!wasSlotHandlerDispatched(event.nativeEvent, event.currentTarget)) {
+ onKeyDownCapture?.(event);
+ }
+ };
+ slotProps.onKeyDown = event => {
+ if (!wasSlotHandlerDispatched(event.nativeEvent, event.currentTarget)) {
+ onKeyDown?.(event);
+ }
+ };
+ return (event: React.KeyboardEvent, capture: boolean, element: T | null) => {
+ if (element && isHTMLElement(event.target) && element.contains(event.target)) {
+ markSlotHandlerDispatched(event.nativeEvent, element);
+ invokeCalendarKeyboardHandler(event, element, capture ? onKeyDownCapture : onKeyDown);
+ }
+ };
+};
const applyCorners = (element: HTMLElement, corners: DayCorners): void => {
element.toggleAttribute('data-corner-top-left', corners.topLeft);
@@ -93,7 +137,6 @@ export const useCalendarDayGridCell_unstable = (
const weeks = useCalendarDayContext_unstable(ctx => ctx.weeks);
const originalDate = day.originalDate;
const cellProps = { ...(!ariaHidden && originalDate ? getDayCellProps?.(originalDate) : undefined), ...rest };
-
const corners = weekCorners?.[weekIndex + '_' + dayIndex];
const isFocusTargetDate =
focusTargetDate && originalDate ? compareDatePart(focusTargetDate, originalDate) === 0 : false;
@@ -271,6 +314,40 @@ export const useCalendarDayGridCell_unstable = (
ariaLabel = formatters.dayMarkedLabel({ date: originalDate, formattedDate });
}
+ const button = slot.always(cellProps.button, {
+ defaultProps: {
+ 'aria-label': ariaLabel,
+ disabled: !ariaHidden && !day.isInBounds,
+ id: isFocusTargetDate ? activeDescendantId : undefined,
+ tabIndex: -1,
+ type: 'button',
+ },
+ elementType: 'button',
+ });
+ const dayLabel = slot.always(cellProps.dayLabel, {
+ defaultProps: {
+ children: originalDate ? formatters.dateTime({ date: originalDate, format: 'day' }) : day.date,
+ },
+ elementType: 'span',
+ });
+ const marker = slot.optional(cellProps.marker, {
+ defaultProps: { 'aria-hidden': true },
+ renderByDefault: day.isMarked,
+ elementType: 'div',
+ });
+ const buttonRef = React.useRef(null);
+ const dayLabelRef = React.useRef(null);
+ const markerRef = React.useRef(null);
+ button.ref = useMergedRefs(button.ref, buttonRef);
+ dayLabel.ref = useMergedRefs(dayLabel.ref, dayLabelRef);
+ const mergedMarkerRef = useMergedRefs(marker?.ref, markerRef);
+ if (marker) {
+ marker.ref = mergedMarkerRef;
+ }
+ const forwardButton = forwardSlotKeyboardHandlers(button);
+ const forwardDayLabel = forwardSlotKeyboardHandlers(dayLabel);
+ const forwardMarker = marker && forwardSlotKeyboardHandlers(marker);
+
const isFocusable = !ariaHidden && !!originalDate && (allFocusable || (day.isInBounds ? true : undefined));
const setCellRef = (element: HTMLTableCellElement) => {
@@ -285,6 +362,59 @@ export const useCalendarDayGridCell_unstable = (
* ref callback runs at commit, not during render.
*/
const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref);
+ const onKeyDown = useEventCallback((ev: React.KeyboardEvent) => {
+ if (isCalendarGridArrow(ev)) {
+ const isSlotRootTarget =
+ ev.target === buttonRef.current || ev.target === dayLabelRef.current || ev.target === markerRef.current;
+ if (isSlotRootTarget) {
+ const slotHandlers = [
+ (capture: boolean) => forwardButton(ev, capture, buttonRef.current),
+ (capture: boolean) => forwardDayLabel(ev, capture, dayLabelRef.current),
+ (capture: boolean) => forwardMarker?.(ev, capture, markerRef.current),
+ ];
+ for (const forward of slotHandlers) {
+ if (!ev.isPropagationStopped()) {
+ forward(true);
+ }
+ }
+ for (const forward of slotHandlers.reverse()) {
+ if (!ev.isPropagationStopped()) {
+ forward(false);
+ }
+ }
+ if (ev.isPropagationStopped()) {
+ return;
+ }
+ }
+ }
+ if (!ev.isPropagationStopped()) {
+ cellProps.onKeyDown?.(ev);
+ }
+ if (
+ !ev.isDefaultPrevented() &&
+ !ariaHidden &&
+ !isCalendarKeyboardEditable(ev.target) &&
+ (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev))
+ ) {
+ onDayKeyDown(ev);
+ }
+ });
+ const ownsArrow = useEventCallback(
+ (ev: React.KeyboardEvent) =>
+ !ariaHidden &&
+ !!originalDate &&
+ isCalendarGridArrow(ev) &&
+ (ev.target === ev.currentTarget ||
+ ev.target === buttonRef.current ||
+ ev.target === dayLabelRef.current ||
+ ev.target === markerRef.current),
+ );
+ const keyDownHandlers = getCalendarGridKeyDownHandlers(
+ onKeyDown,
+ cellProps.onKeyDownCapture,
+ ownsArrow,
+ );
+
const root = slot.always>>(
getIntrinsicElementProps('td', {
...cellProps,
@@ -298,12 +428,8 @@ export const useCalendarDayGridCell_unstable = (
day.onSelected(ev);
}
},
- onKeyDown: (ev: React.KeyboardEvent) => {
- cellProps.onKeyDown?.(ev);
- if (!ev.isDefaultPrevented() && !ariaHidden) {
- onDayKeyDown(ev);
- }
- },
+ onKeyDown: keyDownHandlers.onKeyDown,
+ onKeyDownCapture: keyDownHandlers.onKeyDownCapture,
onMouseDown: !ariaHidden ? mergeCallbacks(cellProps.onMouseDown, onMouseDownDay) : undefined,
onMouseOut: !ariaHidden ? mergeCallbacks(cellProps.onMouseOut, onMouseOutDay) : undefined,
onMouseOver: !ariaHidden ? mergeCallbacks(cellProps.onMouseOver, onMouseOverDay) : undefined,
@@ -343,26 +469,8 @@ export const useCalendarDayGridCell_unstable = (
marker: 'div',
},
root,
- button: slot.always(cellProps.button, {
- defaultProps: {
- 'aria-label': ariaLabel,
- disabled: !ariaHidden && !day.isInBounds,
- id: isFocusTargetDate ? activeDescendantId : undefined,
- tabIndex: -1,
- type: 'button',
- },
- elementType: 'button',
- }),
- dayLabel: slot.always(cellProps.dayLabel, {
- defaultProps: {
- children: originalDate ? formatters.dateTime({ date: originalDate, format: 'day' }) : day.date,
- },
- elementType: 'span',
- }),
- marker: slot.optional(cellProps.marker, {
- defaultProps: { 'aria-hidden': true },
- renderByDefault: day.isMarked,
- elementType: 'div',
- }),
+ button,
+ dayLabel,
+ marker,
};
};
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx
index 12d852fc822fbf..ca3398ee6cb996 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
-import { Enter } from '@fluentui/keyboard-keys';
+import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter } from '@fluentui/keyboard-keys';
import { ArrowDownRegular, ArrowUpRegular } from '@fluentui/react-icons';
import { useArrowNavigationGroup } from '@fluentui/react-tabster';
import { getIntrinsicElementProps, slot, useEventCallback, useMergedRefs } from '@fluentui/react-utilities';
@@ -17,6 +17,7 @@ import {
setMonth,
} from '../../utils';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
+import { CALENDAR_GRID_COLUMNS } from '../../utils/calendarKeyboard';
import { CalendarYear } from '../CalendarYear/CalendarYear';
import { CalendarMonthGridRow } from '../CalendarMonthGridRow/CalendarMonthGridRow';
import type { CalendarYearHandle, CalendarYearProps, CalendarYearSelectData } from '../CalendarYear/CalendarYear.types';
@@ -29,8 +30,6 @@ import type {
CalendarMonthState,
} from './CalendarMonth.types';
-const MONTHS_PER_ROW = 4;
-
const noop = () => undefined;
const onButtonKeyDown =
@@ -47,7 +46,7 @@ const onButtonKeyDown =
/**
* Create the base state required to render an unstyled CalendarMonth.
* Free of Tabster so the headless layer can supply its own roving focus; the styled
- * `useCalendarMonth_unstable` adds arrow key navigation on top.
+ * `useCalendarMonth_unstable` adds Tabster roving focus on top. Base cells own arrow navigation.
*/
export const useCalendarMonthBase_unstable = (
props: CalendarMonthBaseProps,
@@ -212,9 +211,9 @@ export const useCalendarMonthBase_unstable = (
const headerIsClickable = !!onUserHeaderSelect || !yearPickerHidden;
const monthRows: CalendarMonthCell[][] = [];
- for (let rowNum = 0; rowNum < 12 / MONTHS_PER_ROW; rowNum++) {
- const row = Array.from({ length: MONTHS_PER_ROW }, (_, index: number) => {
- const monthIndex = rowNum * MONTHS_PER_ROW + index;
+ for (let rowNum = 0; rowNum < 12 / CALENDAR_GRID_COLUMNS; rowNum++) {
+ const row = Array.from({ length: CALENDAR_GRID_COLUMNS }, (_, index: number) => {
+ const monthIndex = rowNum * CALENDAR_GRID_COLUMNS + index;
const indexedMonth = setMonth(navigatedDate, monthIndex);
return {
@@ -279,7 +278,9 @@ export const useCalendarMonthBase_unstable = (
grid: 'div',
yearPicker: 'div',
},
- root: slot.always(getIntrinsicElementProps('div', props), { elementType: 'div' }),
+ root: slot.always(getIntrinsicElementProps('div', { ...props, 'data-fui-calendar-picker': 'month' }), {
+ elementType: 'div',
+ }),
header: slot.always(header, { elementType: 'div' }),
heading: slot.always(heading, {
defaultProps: {
@@ -350,7 +351,15 @@ export const useCalendarMonth_unstable = (
ref: React.Ref,
): CalendarMonthState => {
const baseState = useCalendarMonthBase_unstable(props, ref);
- const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' });
+ const arrowNavigationAttributes = useArrowNavigationGroup({
+ axis: 'grid',
+ ignoreDefaultKeydown: {
+ [ArrowDown]: true,
+ [ArrowLeft]: true,
+ [ArrowRight]: true,
+ [ArrowUp]: true,
+ },
+ });
const yearPicker = slot.always(props.yearPicker, {
defaultProps: baseState.yearPicker,
elementType: CalendarYear,
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts
index fc3a54f3c629ac..d273f7d540baa1 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts
@@ -2,10 +2,17 @@
import type * as React from 'react';
import { Enter } from '@fluentui/keyboard-keys';
+import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import { getIntrinsicElementProps, slot, useMergedRefs } from '@fluentui/react-utilities';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
import { useCalendarMonthContext_unstable } from '../../contexts/calendarMonthContext';
import { stringifyDataAttribute } from '../../utils';
+import {
+ getCalendarGridKeyDownHandlers,
+ handleCalendarGridKeyDown,
+ isCalendarGridArrow,
+ isCalendarKeyboardEditable,
+} from '../../utils/calendarKeyboard';
import type { ExtractSlotProps, Slot } from '@fluentui/react-utilities';
import type { CalendarMonthGridCellProps, CalendarMonthGridCellState } from './CalendarMonthGridCell.types';
@@ -17,11 +24,27 @@ export const useCalendarMonthGridCell_unstable = (
ref?: React.Ref,
): CalendarMonthGridCellState => {
const { month } = props;
+ const { dir } = useFluent_unstable();
const nativeProps = getIntrinsicElementProps('button', props, ['month']);
const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable);
const navigatedMonthRef = useCalendarMonthContext_unstable(ctx => ctx.navigatedMonthRef);
const onSelect = month.isInBounds ? month.onSelect : undefined;
const rootRef = useMergedRefs(ref, month.isNavigated ? navigatedMonthRef : undefined);
+ const keyDownHandlers = getCalendarGridKeyDownHandlers(
+ event => {
+ nativeProps.onKeyDown?.(event);
+ if (event.isDefaultPrevented() || isCalendarKeyboardEditable(event.target)) {
+ return;
+ }
+ handleCalendarGridKeyDown(event, dir, allFocusable);
+ if (event.key === Enter && onSelect) {
+ event.preventDefault();
+ onSelect(event);
+ }
+ },
+ nativeProps.onKeyDownCapture,
+ isCalendarGridArrow,
+ );
const root = slot.always(
{
@@ -39,13 +62,8 @@ export const useCalendarMonthGridCell_unstable = (
onSelect?.(event);
}
},
- onKeyDown: event => {
- nativeProps.onKeyDown?.(event);
- if (!event.isDefaultPrevented() && event.key === Enter && onSelect) {
- event.preventDefault();
- onSelect(event);
- }
- },
+ onKeyDown: keyDownHandlers.onKeyDown,
+ onKeyDownCapture: keyDownHandlers.onKeyDownCapture,
'aria-label': month.ariaLabel,
'aria-disabled': !month.isInBounds,
'aria-selected': month.isSelected,
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx
index c2b9a925550ae0..7c1371f1db40d8 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx
@@ -1,12 +1,13 @@
'use client';
import * as React from 'react';
-import { Enter, Space } from '@fluentui/keyboard-keys';
+import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter, Space } from '@fluentui/keyboard-keys';
import { ArrowDownRegular, ArrowUpRegular } from '@fluentui/react-icons';
import { useArrowNavigationGroup } from '@fluentui/react-tabster';
import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
import { createDate } from '../../utils/dateMath';
+import { CALENDAR_GRID_COLUMNS } from '../../utils/calendarKeyboard';
import { CalendarYearGridRow } from '../CalendarYearGridRow/CalendarYearGridRow';
import type {
CalendarYearBaseProps,
@@ -19,7 +20,6 @@ import type {
} from './CalendarYear.types';
const CELL_COUNT = 12;
-const CELLS_PER_ROW = 4;
function useYearRangeState({
selectedYear,
@@ -68,7 +68,7 @@ function useYearRangeState({
/**
* Create the base state required to render an unstyled CalendarYear.
* Free of Tabster so the headless layer can supply its own roving focus; the styled
- * `useCalendarYear_unstable` adds arrow key navigation on top.
+ * `useCalendarYear_unstable` adds Tabster roving focus on top. Base cells own arrow navigation.
*/
export const useCalendarYearBase_unstable = (
props: CalendarYearBaseProps,
@@ -120,10 +120,10 @@ export const useCalendarYearBase_unstable = (
Math.max(firstFocusableYear, props.navigatedYear ?? selectedYear ?? currentYear),
);
const yearRows: CalendarYearCell[][] = [];
- for (let row = 0; row < CELL_COUNT / CELLS_PER_ROW; row++) {
+ for (let row = 0; row < CELL_COUNT / CALENDAR_GRID_COLUMNS; row++) {
const cells: CalendarYearCell[] = [];
- for (let column = 0; column < CELLS_PER_ROW; column++) {
- const year = fromYear + row * CELLS_PER_ROW + column;
+ for (let column = 0; column < CALENDAR_GRID_COLUMNS; column++) {
+ const year = fromYear + row * CALENDAR_GRID_COLUMNS + column;
cells.push({
year,
content: props.renderYear ? props.renderYear(year) : formatYear(year),
@@ -250,7 +250,15 @@ export const useCalendarYear_unstable = (
ref: React.Ref,
): CalendarYearState => {
const baseState = useCalendarYearBase_unstable(props, ref);
- const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' });
+ const arrowNavigationAttributes = useArrowNavigationGroup({
+ axis: 'grid',
+ ignoreDefaultKeydown: {
+ [ArrowDown]: true,
+ [ArrowLeft]: true,
+ [ArrowRight]: true,
+ [ArrowUp]: true,
+ },
+ });
return {
...baseState,
diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts
index 3e8cccdc0a7db4..28b5485c10fb3d 100644
--- a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts
+++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts
@@ -2,10 +2,17 @@
import type * as React from 'react';
import { Enter } from '@fluentui/keyboard-keys';
+import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import { getIntrinsicElementProps, slot, useMergedRefs } from '@fluentui/react-utilities';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
import { useCalendarYearContext_unstable } from '../../contexts/calendarYearContext';
import { stringifyDataAttribute } from '../../utils';
+import {
+ getCalendarGridKeyDownHandlers,
+ handleCalendarGridKeyDown,
+ isCalendarGridArrow,
+ isCalendarKeyboardEditable,
+} from '../../utils/calendarKeyboard';
import type { ExtractSlotProps, Slot } from '@fluentui/react-utilities';
import type { CalendarYearGridCellProps, CalendarYearGridCellState } from './CalendarYearGridCell.types';
@@ -17,6 +24,7 @@ export const useCalendarYearGridCell_unstable = (
ref: React.Ref,
): CalendarYearGridCellState => {
const { cell } = props;
+ const { dir } = useFluent_unstable();
const nativeProps = getIntrinsicElementProps('button', props, ['cell']);
const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable);
const currentYearRef = useCalendarYearContext_unstable(ctx => ctx.currentYearRef);
@@ -39,6 +47,22 @@ export const useCalendarYearGridCell_unstable = (
year: cell.year,
});
+ const keyDownHandlers = getCalendarGridKeyDownHandlers(
+ event => {
+ nativeProps.onKeyDown?.(event);
+ if (event.isDefaultPrevented() || isCalendarKeyboardEditable(event.target)) {
+ return;
+ }
+ handleCalendarGridKeyDown(event, dir, allFocusable);
+ if (event.key === Enter && onSelect) {
+ event.preventDefault();
+ onSelect(event);
+ }
+ },
+ nativeProps.onKeyDownCapture,
+ event => isCalendarGridArrow(event) && event.target === event.currentTarget,
+ );
+
const root = slot.always>>(
{
...nativeProps,
@@ -52,13 +76,8 @@ export const useCalendarYearGridCell_unstable = (
onSelect?.(event);
}
},
- onKeyDown: event => {
- nativeProps.onKeyDown?.(event);
- if (!event.isDefaultPrevented() && event.key === Enter && onSelect) {
- event.preventDefault();
- onSelect(event);
- }
- },
+ onKeyDown: keyDownHandlers.onKeyDown,
+ onKeyDownCapture: keyDownHandlers.onKeyDownCapture,
disabled: cell.isDisabled && !allFocusable,
'aria-disabled': cell.isDisabled,
tabIndex: cell.isDisabled && !allFocusable ? -1 : 0,
diff --git a/packages/react-components/react-calendar-preview/library/src/utils/calendarKeyboard.ts b/packages/react-components/react-calendar-preview/library/src/utils/calendarKeyboard.ts
index 994a14d3213935..c0ee0b29624570 100644
--- a/packages/react-components/react-calendar-preview/library/src/utils/calendarKeyboard.ts
+++ b/packages/react-components/react-calendar-preview/library/src/utils/calendarKeyboard.ts
@@ -1,5 +1,12 @@
+import type * as React from 'react';
+import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from '@fluentui/keyboard-keys';
+import { getRTLSafeKey, isHTMLElement } from '@fluentui/react-utilities';
+
+export const CALENDAR_GRID_COLUMNS = 4;
+
// The grid supplies the focused date while Calendar retains ownership of paging and consumer cancellation.
const navigationDates = new WeakMap();
+const capturedArrows = new WeakSet();
export const setCalendarNavigationDate = (event: Event, date: Date): void => {
navigationDates.set(event, date);
@@ -10,3 +17,150 @@ export const getCalendarNavigationDate = (event: Event): Date | undefined => {
navigationDates.delete(event);
return date;
};
+
+export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => {
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+
+ if (target.closest('input, textarea, select')) {
+ return true;
+ }
+
+ let element: HTMLElement | null = target;
+ while (element) {
+ const contentEditable = element.getAttribute('contenteditable')?.trim().toLowerCase();
+ if (contentEditable === '' || contentEditable === 'true' || contentEditable === 'plaintext-only') {
+ return true;
+ }
+ if (contentEditable === 'false') {
+ return false;
+ }
+ element = element.parentElement;
+ }
+ return false;
+};
+
+export const getCalendarRootActiveElement = (element: HTMLElement): Element | null => {
+ const root = element.getRootNode();
+ if (root.nodeType === 9) {
+ return (root as Document).activeElement;
+ }
+ if (root.nodeType === 11 && 'host' in root) {
+ return (root as ShadowRoot).activeElement;
+ }
+ return null;
+};
+
+export const isCalendarArrowKey = (key: string): boolean =>
+ key === ArrowDown || key === ArrowLeft || key === ArrowRight || key === ArrowUp;
+
+export const isCalendarGridCellEvent = (event: React.KeyboardEvent): boolean => {
+ const grid = event.currentTarget.closest('[role="grid"]');
+ return (
+ !!grid &&
+ isHTMLElement(event.target) &&
+ !isCalendarKeyboardEditable(event.target) &&
+ event.target.closest('[role="gridcell"]') === event.currentTarget &&
+ event.target.closest('[role="grid"], [focusgroup]') === grid
+ );
+};
+
+export const isCalendarGridArrow = (event: React.KeyboardEvent): boolean =>
+ isCalendarArrowKey(event.key) &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ isCalendarGridCellEvent(event);
+
+const withCurrentTarget = (
+ event: React.KeyboardEvent,
+ currentTarget: T,
+): React.KeyboardEvent =>
+ new Proxy(event, {
+ get: (target, property) => (property === 'currentTarget' ? currentTarget : Reflect.get(target, property, target)),
+ }) as React.KeyboardEvent;
+
+export const getCalendarGridKeyDownHandlers = (
+ onKeyDown: React.KeyboardEventHandler,
+ onKeyDownCapture: React.KeyboardEventHandler | undefined,
+ ownsArrow: (event: React.KeyboardEvent) => boolean,
+): Required, 'onKeyDown' | 'onKeyDownCapture'>> => ({
+ onKeyDownCapture: event => {
+ onKeyDownCapture?.(event);
+ if (event.isPropagationStopped() || !ownsArrow(event)) {
+ return;
+ }
+
+ // Dispatch before native grid bubbling, then suppress the later synthetic bubble.
+ capturedArrows.add(event.nativeEvent);
+ onKeyDown(event);
+ event.preventDefault();
+ },
+ onKeyDown: event => {
+ if (!capturedArrows.delete(event.nativeEvent)) {
+ onKeyDown(event);
+ }
+ },
+});
+
+export const invokeCalendarKeyboardHandler = (
+ event: React.KeyboardEvent,
+ currentTarget: T,
+ handler: React.KeyboardEventHandler | undefined,
+): void => {
+ handler?.(withCurrentTarget(event, currentTarget));
+};
+
+export const handleCalendarGridKeyDown = (
+ event: React.KeyboardEvent,
+ dir: 'ltr' | 'rtl',
+ allFocusable: boolean,
+): void => {
+ const { currentTarget } = event;
+ if (event.isDefaultPrevented() || !isCalendarGridArrow(event)) {
+ return;
+ }
+
+ let step: number;
+ if (event.key === ArrowDown) {
+ step = CALENDAR_GRID_COLUMNS;
+ } else if (event.key === ArrowUp) {
+ step = -CALENDAR_GRID_COLUMNS;
+ } else if (event.key === getRTLSafeKey(ArrowRight, dir)) {
+ step = 1;
+ } else if (event.key === getRTLSafeKey(ArrowLeft, dir)) {
+ step = -1;
+ } else {
+ return;
+ }
+ const grid = currentTarget.closest('[role="grid"]');
+ if (!grid) {
+ return;
+ }
+
+ const cells = Array.from(grid.querySelectorAll('[role="gridcell"]')).filter(
+ cell => cell.closest('[role="grid"]') === grid,
+ );
+ const currentIndex = cells.indexOf(currentTarget);
+ if (currentIndex < 0) {
+ return;
+ }
+
+ // Own boundary keys too so a native focusgroup cannot fall back to linear movement.
+ event.preventDefault();
+ const currentRow = Math.floor(currentIndex / CALENDAR_GRID_COLUMNS);
+ for (let index = currentIndex + step; index >= 0 && index < cells.length; index += step) {
+ if (Math.abs(step) === 1 && Math.floor(index / CALENDAR_GRID_COLUMNS) !== currentRow) {
+ break;
+ }
+ const cell = cells[index];
+ if (!cell.matches(':disabled') && (allFocusable || cell.getAttribute('aria-disabled') !== 'true')) {
+ cell.focus();
+ if (getCalendarRootActiveElement(cell) === cell) {
+ break;
+ }
+ }
+ }
+};