From 346682c3948270fb2b77c63f28596ca583ac6220 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 5 Sep 2026 13:31:41 +0200 Subject: [PATCH 01/16] chore(react-calendar-preview): prepare initial release --- ...endar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json | 7 +++++++ .../react-calendar-preview/library/package.json | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json diff --git a/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json b/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json new file mode 100644 index 00000000000000..661f914c2a4f67 --- /dev/null +++ b/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "chore: initial release", + "packageName": "@fluentui/react-calendar-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/packages/react-components/react-calendar-preview/library/package.json b/packages/react-components/react-calendar-preview/library/package.json index 691f19eb1cee5e..bdf1bc546e79a1 100644 --- a/packages/react-components/react-calendar-preview/library/package.json +++ b/packages/react-components/react-calendar-preview/library/package.json @@ -1,7 +1,6 @@ { "name": "@fluentui/react-calendar-preview", "version": "0.0.0", - "private": true, "description": "Fluent UI React Calendar components", "type": "module", "main": "lib-commonjs/index.cjs", From 247264a166ea6222fad835ae2c9bc74b7e12ed93 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 13 Oct 2026 17:28:07 +0200 Subject: [PATCH 02/16] fix(ci): conditionally cache Yarn for deduplication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- .github/workflows/check-packages.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check-packages.yml b/.github/workflows/check-packages.yml index be040be283583a..7015397b363a7f 100644 --- a/.github/workflows/check-packages.yml +++ b/.github/workflows/check-packages.yml @@ -11,17 +11,18 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: '22' - cache: 'yarn' - - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 id: changed-files-specific with: files: | yarn.lock + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + # Only cache Yarn when the conditional install below populates its cache directory. + cache: ${{ steps.changed-files-specific.outputs.any_changed == 'true' && 'yarn' || '' }} + - run: yarn install --immutable if: steps.changed-files-specific.outputs.any_changed == 'true' From 792c666ab1509b9676b9d9e90e61eae23f9d72fd Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 13 Oct 2026 17:45:20 +0200 Subject: [PATCH 03/16] fix(ci): build the LLM extractor before the v9 docsite Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- apps/public-docsite-v9/README.md | 5 +++++ apps/public-docsite-v9/project.json | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/public-docsite-v9/README.md b/apps/public-docsite-v9/README.md index 7427bec5facac5..0904d0d53263a2 100644 --- a/apps/public-docsite-v9/README.md +++ b/apps/public-docsite-v9/README.md @@ -10,6 +10,11 @@ Fluent UI is a collection of projects that represent the Fluent design language Run `yarn workspace @fluentui/public-docsite-v9 start` +## Build the website + +Run `yarn nx run public-docsite-v9:build-storybook` to build the production Storybook and generate LLM documentation. +Nx builds `storybook-llms-extractor` before running the documentation generation step. + ## Notes - `Open in CodeSandbox` button is located in different [repo](https://github.com/microsoft/fluentui-storybook-addons). diff --git a/apps/public-docsite-v9/project.json b/apps/public-docsite-v9/project.json index cbc7b2cc1e6974..fb4d874aea2cca 100644 --- a/apps/public-docsite-v9/project.json +++ b/apps/public-docsite-v9/project.json @@ -12,7 +12,13 @@ "{projectRoot}/.storybook/**", { "env": "FLUENTUI_ICON_VARIANT" } ], - "dependsOn": ["^build"] + "dependsOn": [ + "^build", + { + "projects": ["storybook-llms-extractor"], + "target": "build" + } + ] }, "storybook": { "dependsOn": ["^build"] From f8ad53c22ea1cc1c8e739dd71c301042e8bcb09f Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 13 Oct 2026 20:50:30 +0200 Subject: [PATCH 04/16] fix(react-calendar-preview): track picker focus without styling classes Use reserved day/month root markers to preserve focus restoration for base and headless compositions. Add styled/classless and SSR regressions, document the markers, and include a patch change file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- ...-796032a8-04f9-4b57-ac63-64964dcbdbc3.json | 6 + .../library/docs/Spec.md | 8 +- .../Calendar/Calendar-node.test.tsx | 17 ++ .../Calendar/Calendar.focus.test.tsx | 272 ++++++++++++++++++ .../src/components/Calendar/useCalendar.tsx | 12 +- .../components/CalendarDay/useCalendarDay.tsx | 4 +- .../CalendarMonth/useCalendarMonth.tsx | 4 +- 7 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx diff --git a/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json b/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json new file mode 100644 index 00000000000000..4c9dc764578971 --- /dev/null +++ b/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: restore Calendar focus independently of styling classes", + "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..92e6d72535358a 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,10 @@ 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. +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 +260,7 @@ The default DOM is equivalent to:
Selected: June 18, 2025
-
+
June 2025
@@ -279,7 +283,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.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( + <> + + + , + ); + 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( + <> + + + , + ); + act(() => getByRole('gridcell', { name: 'March' }).focus()); + rerender( + <> + + + , + ); + 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/useCalendar.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx index b6784fd468565e..3e1f4cddc2d95d 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 @@ -398,14 +398,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/CalendarMonth/useCalendarMonth.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx index 12d852fc822fbf..c9b6786226d737 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 @@ -279,7 +279,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: { From c21fd6deddb5fe4654a3cd5d0f8294bdcec67238 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 13 Oct 2026 22:01:19 +0200 Subject: [PATCH 05/16] fix(react-calendar-preview): restore base grid keyboard ownership Handle month/year arrows by semantic row and column in the base layer. Dispatch owned day/month/year arrows through consumer-first capture handlers before native grid owners, without duplicate bubbling callbacks. Preserve editable targets, RTL, availability and non-arrow bubbling; keep styled Tabster roving behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- ...-8e28f4bf-0473-417b-9d10-124b9ce21595.json | 6 + .../library/docs/Spec.md | 11 + .../src/components/Calendar/Calendar.cy.tsx | 116 +++- .../Calendar/Calendar.gridKeyboard.test.tsx | 588 ++++++++++++++++++ .../src/components/Calendar/useCalendar.tsx | 8 +- .../useCalendarDayGridCell.ts | 29 +- .../CalendarMonth/useCalendarMonth.tsx | 23 +- .../useCalendarMonthGridCell.ts | 30 +- .../CalendarYear/useCalendarYear.tsx | 22 +- .../useCalendarYearGridCell.ts | 30 +- .../library/src/utils/calendarKeyboard.ts | 106 ++++ 11 files changed, 925 insertions(+), 44 deletions(-) create mode 100644 change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx diff --git a/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json b/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json new file mode 100644 index 00000000000000..3d0a1cc507d8ec --- /dev/null +++ b/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: restore Calendar grid arrows and preserve native keyboard ownership", + "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 92e6d72535358a..38ef15af53625c 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,17 @@ 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. +Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch. + 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. 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..5631b7df245963 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} + , + ); }; /** @@ -243,6 +247,114 @@ 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(`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( + <> + + + , + ); + 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'); + } + }); + }); + }); + it('keeps the month highlight on the navigated month', () => { mountFluent(); 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..9a6eb49119ca72 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx @@ -0,0 +1,588 @@ +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 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; +}; + +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, ...rowProps } = fixtureProps; + return ( + + + {picker === 'day' ? ( + ({ + onKeyDown: rowProps.onCellKeyDown, + onKeyDownCapture: rowProps.onCellKeyDownCapture, + })} + 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)), + }; +} + +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, + }); + } + }); + + 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.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 }) => { + 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(['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('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 3e1f4cddc2d95d..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; 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..72bfbbb9579009 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 @@ -12,7 +12,13 @@ import { 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, + isCalendarArrowKey, + isCalendarGridCellEvent, + isCalendarKeyboardEditable, + setCalendarNavigationDate, +} from '../../utils/calendarKeyboard'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarDayContext_unstable } from '../../contexts/calendarDayContext'; import type { AvailableDateOptions } from '../../utils'; @@ -298,12 +304,21 @@ export const useCalendarDayGridCell_unstable = ( day.onSelected(ev); } }, - onKeyDown: (ev: React.KeyboardEvent) => { - cellProps.onKeyDown?.(ev); - if (!ev.isDefaultPrevented() && !ariaHidden) { - onDayKeyDown(ev); - } - }, + ...getCalendarGridKeyDownHandlers( + ev => { + cellProps.onKeyDown?.(ev); + if ( + !ev.isDefaultPrevented() && + !ariaHidden && + !isCalendarKeyboardEditable(ev.target) && + (!isCalendarArrowKey(ev.key) || isCalendarGridCellEvent(ev)) + ) { + onDayKeyDown(ev); + } + }, + cellProps.onKeyDownCapture, + ev => !ariaHidden && !!originalDate && isCalendarArrowKey(ev.key) && isCalendarGridCellEvent(ev), + ), onMouseDown: !ariaHidden ? mergeCallbacks(cellProps.onMouseDown, onMouseDownDay) : undefined, onMouseOut: !ariaHidden ? mergeCallbacks(cellProps.onMouseOut, onMouseOutDay) : undefined, onMouseOver: !ariaHidden ? mergeCallbacks(cellProps.onMouseOver, onMouseOverDay) : undefined, 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 c9b6786226d737..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 { @@ -352,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..a4512f7d060260 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,6 +24,7 @@ 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); @@ -39,13 +47,21 @@ export const useCalendarMonthGridCell_unstable = ( onSelect?.(event); } }, - onKeyDown: event => { - nativeProps.onKeyDown?.(event); - if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { - event.preventDefault(); - onSelect(event); - } - }, + ...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, + ), '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..83657738908b61 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); @@ -52,13 +60,21 @@ export const useCalendarYearGridCell_unstable = ( onSelect?.(event); } }, - onKeyDown: event => { - nativeProps.onKeyDown?.(event); - if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { - event.preventDefault(); - onSelect(event); - } - }, + ...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, + ), 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..c3478d14a8baf7 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,102 @@ export const getCalendarNavigationDate = (event: Event): Date | undefined => { navigationDates.delete(event); return date; }; + +export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => + isHTMLElement(target) && + !!target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); + +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"]') === grid + ); +}; + +export const isCalendarGridArrow = (event: React.KeyboardEvent): boolean => + isCalendarArrowKey(event.key) && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + isCalendarGridCellEvent(event); + +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; + } + + // Native grid owners bubble before React; dispatch here and keep ownership across rerenders. + capturedArrows.add(event.nativeEvent); + onKeyDown(event); + event.preventDefault(); + }, + onKeyDown: event => { + if (!capturedArrows.delete(event.nativeEvent)) { + onKeyDown(event); + } + }, +}); + +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(); + break; + } + } +}; From d18187c12285eff99a0ac4a6939aa02c445de8eb Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 14 Oct 2026 11:34:09 +0200 Subject: [PATCH 06/16] fix(react-calendar-preview): preserve nested focusgroup arrow ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- ...-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json | 6 + .../library/docs/Spec.md | 3 + .../Calendar/Calendar.gridKeyboard.test.tsx | 120 ++++++++++++++++++ .../library/src/utils/calendarKeyboard.ts | 2 +- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json diff --git a/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json b/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json new file mode 100644 index 00000000000000..84b2ae65246bc5 --- /dev/null +++ b/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: preserve Calendar arrows in nested focusgroups", + "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 38ef15af53625c..0e9e9f5e512fb1 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -257,6 +257,9 @@ Handled arrows, including boundary keys, are prevented so Tabster or a native fo 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. +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. Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch. The day and month base hooks reserve `data-fui-calendar-picker="day"` and `data-fui-calendar-picker="month"` on their roots. 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 index 9a6eb49119ca72..9884898728f98c 100644 --- 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 @@ -192,6 +192,22 @@ function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: Fi }; } +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 }, @@ -452,6 +468,110 @@ describe.each([ 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[] = []; 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 c3478d14a8baf7..a7eb0b5ab46aa6 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 @@ -32,7 +32,7 @@ export const isCalendarGridCellEvent = (event: React.KeyboardEvent) isHTMLElement(event.target) && !isCalendarKeyboardEditable(event.target) && event.target.closest('[role="gridcell"]') === event.currentTarget && - event.target.closest('[role="grid"]') === grid + event.target.closest('[role="grid"], [focusgroup]') === grid ); }; From 83f6d20939031247eddcb3dab424393ee7eb5723 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 14 Oct 2026 21:09:01 +0200 Subject: [PATCH 07/16] chore(react-calendar-preview): limit release PR to publication metadata Revert Calendar behavior fixes for a separate PR and remove the unrelated CI changes, restoring the original two-file release tree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed39bb7d-827b-40e9-bc3b-3bf27e334d85 --- .github/workflows/check-packages.yml | 11 +- apps/public-docsite-v9/README.md | 5 - apps/public-docsite-v9/project.json | 8 +- ...-796032a8-04f9-4b57-ac63-64964dcbdbc3.json | 6 - ...-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json | 6 - ...-8e28f4bf-0473-417b-9d10-124b9ce21595.json | 6 - .../library/docs/Spec.md | 22 +- .../Calendar/Calendar-node.test.tsx | 17 - .../src/components/Calendar/Calendar.cy.tsx | 116 +-- .../Calendar/Calendar.focus.test.tsx | 272 ------- .../Calendar/Calendar.gridKeyboard.test.tsx | 708 ------------------ .../src/components/Calendar/useCalendar.tsx | 20 +- .../components/CalendarDay/useCalendarDay.tsx | 4 +- .../useCalendarDayGridCell.ts | 29 +- .../CalendarMonth/useCalendarMonth.tsx | 27 +- .../useCalendarMonthGridCell.ts | 30 +- .../CalendarYear/useCalendarYear.tsx | 22 +- .../useCalendarYearGridCell.ts | 30 +- .../library/src/utils/calendarKeyboard.ts | 106 --- 19 files changed, 62 insertions(+), 1383 deletions(-) delete mode 100644 change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json delete mode 100644 change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json delete mode 100644 change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json delete mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx delete mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx delete mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx diff --git a/.github/workflows/check-packages.yml b/.github/workflows/check-packages.yml index 7015397b363a7f..be040be283583a 100644 --- a/.github/workflows/check-packages.yml +++ b/.github/workflows/check-packages.yml @@ -11,18 +11,17 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + cache: 'yarn' + - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 id: changed-files-specific with: files: | yarn.lock - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: '22' - # Only cache Yarn when the conditional install below populates its cache directory. - cache: ${{ steps.changed-files-specific.outputs.any_changed == 'true' && 'yarn' || '' }} - - run: yarn install --immutable if: steps.changed-files-specific.outputs.any_changed == 'true' diff --git a/apps/public-docsite-v9/README.md b/apps/public-docsite-v9/README.md index 0904d0d53263a2..7427bec5facac5 100644 --- a/apps/public-docsite-v9/README.md +++ b/apps/public-docsite-v9/README.md @@ -10,11 +10,6 @@ Fluent UI is a collection of projects that represent the Fluent design language Run `yarn workspace @fluentui/public-docsite-v9 start` -## Build the website - -Run `yarn nx run public-docsite-v9:build-storybook` to build the production Storybook and generate LLM documentation. -Nx builds `storybook-llms-extractor` before running the documentation generation step. - ## Notes - `Open in CodeSandbox` button is located in different [repo](https://github.com/microsoft/fluentui-storybook-addons). diff --git a/apps/public-docsite-v9/project.json b/apps/public-docsite-v9/project.json index fb4d874aea2cca..cbc7b2cc1e6974 100644 --- a/apps/public-docsite-v9/project.json +++ b/apps/public-docsite-v9/project.json @@ -12,13 +12,7 @@ "{projectRoot}/.storybook/**", { "env": "FLUENTUI_ICON_VARIANT" } ], - "dependsOn": [ - "^build", - { - "projects": ["storybook-llms-extractor"], - "target": "build" - } - ] + "dependsOn": ["^build"] }, "storybook": { "dependsOn": ["^build"] diff --git a/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json b/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json deleted file mode 100644 index 4c9dc764578971..00000000000000 --- a/change/@fluentui-react-calendar-preview-796032a8-04f9-4b57-ac63-64964dcbdbc3.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "patch", - "comment": "fix: restore Calendar focus independently of styling classes", - "packageName": "@fluentui/react-calendar-preview", - "email": "dmytrokirpa@microsoft.com" -} diff --git a/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json b/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json deleted file mode 100644 index 84b2ae65246bc5..00000000000000 --- a/change/@fluentui-react-calendar-preview-7cfbd6e2-bd84-41bd-96b8-5130b5695ad2.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "patch", - "comment": "fix: preserve Calendar arrows in nested focusgroups", - "packageName": "@fluentui/react-calendar-preview", - "email": "dmytrokirpa@microsoft.com" -} diff --git a/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json b/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json deleted file mode 100644 index 3d0a1cc507d8ec..00000000000000 --- a/change/@fluentui-react-calendar-preview-8e28f4bf-0473-417b-9d10-124b9ce21595.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "patch", - "comment": "fix: restore Calendar grid arrows and preserve native keyboard ownership", - "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 0e9e9f5e512fb1..00f44b87442842 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -248,24 +248,6 @@ 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. -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. -Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch. - -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: @@ -274,7 +256,7 @@ The default DOM is equivalent to:
Selected: June 18, 2025
-
+
June 2025
@@ -297,7 +279,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 deleted file mode 100644 index a7f66ba18f8eca..00000000000000 --- a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/* - * @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 5631b7df245963..879d78ed31fe2d 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,12 +12,8 @@ import { calendarMonthGridCellClassNames } from '../CalendarMonthGridCell/useCal import { calendarYearGridCellClassNames } from '../CalendarYearGridCell/useCalendarYearGridCellStyles.styles'; import type { JSXElement } from '@fluentui/react-utilities'; -const mountFluent = (element: JSXElement, dir: 'ltr' | 'rtl' = 'ltr') => { - mount( - - {element} - , - ); +const mountFluent = (element: JSXElement) => { + mount({element}); }; /** @@ -247,114 +243,6 @@ 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(`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( - <> - - - , - ); - 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'); - } - }); - }); - }); - 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 deleted file mode 100644 index 5e7e3131cde283..00000000000000 --- a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx +++ /dev/null @@ -1,272 +0,0 @@ -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( - <> - - - , - ); - 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( - <> - - - , - ); - act(() => getByRole('gridcell', { name: 'March' }).focus()); - rerender( - <> - - - , - ); - 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 deleted file mode 100644 index 9884898728f98c..00000000000000 --- a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx +++ /dev/null @@ -1,708 +0,0 @@ -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 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; -}; - -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, ...rowProps } = fixtureProps; - return ( - - - {picker === 'day' ? ( - ({ - onKeyDown: rowProps.onCellKeyDown, - onKeyDownCapture: rowProps.onCellKeyDownCapture, - })} - 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, - }); - } - }); - - 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.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 }) => { - 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('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 415af505c942b3..b6784fd468565e 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,6 +4,7 @@ import * as React from 'react'; import { Enter, Escape, PageDown, PageUp } from '@fluentui/keyboard-keys'; import { getIntrinsicElementProps, + isHTMLElement, slot, useAnimationFrame, useControllableState, @@ -21,7 +22,7 @@ import { } from '../../utils'; import { CalendarDay } from '../CalendarDay/CalendarDay'; import { CalendarMonth } from '../CalendarMonth/CalendarMonth'; -import { getCalendarNavigationDate, isCalendarKeyboardEditable } from '../../utils/calendarKeyboard'; +import { getCalendarNavigationDate } from '../../utils/calendarKeyboard'; import type { DayOfWeek } from '../../utils'; import type { CalendarDayHandle, @@ -374,7 +375,10 @@ export const useCalendarBase_unstable = ( case PageUp: case PageDown: { - if (isCalendarKeyboardEditable(ev.target)) { + if ( + isHTMLElement(ev.target) && + ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') + ) { break; } const date = getCalendarNavigationDate(ev.nativeEvent) ?? navigatedDate; @@ -394,10 +398,14 @@ export const useCalendarBase_unstable = ( isFocusWithin.current = true; props.onFocusCapture?.(ev); - 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 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 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 80a3b78e556755..2f60576e622044 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,9 +307,7 @@ export const useCalendarDayBase_unstable = ( grid: 'table', body: 'tbody', }, - root: slot.always(getIntrinsicElementProps('div', { ...props, 'data-fui-calendar-picker': 'day' }), { - elementType: 'div', - }), + root: slot.always(getIntrinsicElementProps('div', props), { 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 72bfbbb9579009..5bd611fc78a575 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 @@ -12,13 +12,7 @@ import { import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { compareDatePart, findAvailableDate, stringifyDataAttribute } from '../../utils'; import { createDate } from '../../utils/dateMath'; -import { - getCalendarGridKeyDownHandlers, - isCalendarArrowKey, - isCalendarGridCellEvent, - isCalendarKeyboardEditable, - setCalendarNavigationDate, -} from '../../utils/calendarKeyboard'; +import { setCalendarNavigationDate } from '../../utils/calendarKeyboard'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarDayContext_unstable } from '../../contexts/calendarDayContext'; import type { AvailableDateOptions } from '../../utils'; @@ -304,21 +298,12 @@ export const useCalendarDayGridCell_unstable = ( day.onSelected(ev); } }, - ...getCalendarGridKeyDownHandlers( - ev => { - cellProps.onKeyDown?.(ev); - if ( - !ev.isDefaultPrevented() && - !ariaHidden && - !isCalendarKeyboardEditable(ev.target) && - (!isCalendarArrowKey(ev.key) || isCalendarGridCellEvent(ev)) - ) { - onDayKeyDown(ev); - } - }, - cellProps.onKeyDownCapture, - ev => !ariaHidden && !!originalDate && isCalendarArrowKey(ev.key) && isCalendarGridCellEvent(ev), - ), + onKeyDown: (ev: React.KeyboardEvent) => { + cellProps.onKeyDown?.(ev); + if (!ev.isDefaultPrevented() && !ariaHidden) { + onDayKeyDown(ev); + } + }, onMouseDown: !ariaHidden ? mergeCallbacks(cellProps.onMouseDown, onMouseDownDay) : undefined, onMouseOut: !ariaHidden ? mergeCallbacks(cellProps.onMouseOut, onMouseOutDay) : undefined, onMouseOver: !ariaHidden ? mergeCallbacks(cellProps.onMouseOver, onMouseOverDay) : undefined, 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 ca3398ee6cb996..12d852fc822fbf 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 { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter } from '@fluentui/keyboard-keys'; +import { 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,7 +17,6 @@ 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'; @@ -30,6 +29,8 @@ import type { CalendarMonthState, } from './CalendarMonth.types'; +const MONTHS_PER_ROW = 4; + const noop = () => undefined; const onButtonKeyDown = @@ -46,7 +47,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 Tabster roving focus on top. Base cells own arrow navigation. + * `useCalendarMonth_unstable` adds arrow key navigation on top. */ export const useCalendarMonthBase_unstable = ( props: CalendarMonthBaseProps, @@ -211,9 +212,9 @@ export const useCalendarMonthBase_unstable = ( const headerIsClickable = !!onUserHeaderSelect || !yearPickerHidden; const monthRows: CalendarMonthCell[][] = []; - 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; + 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; const indexedMonth = setMonth(navigatedDate, monthIndex); return { @@ -278,9 +279,7 @@ export const useCalendarMonthBase_unstable = ( grid: 'div', yearPicker: 'div', }, - root: slot.always(getIntrinsicElementProps('div', { ...props, 'data-fui-calendar-picker': 'month' }), { - elementType: 'div', - }), + root: slot.always(getIntrinsicElementProps('div', props), { elementType: 'div' }), header: slot.always(header, { elementType: 'div' }), heading: slot.always(heading, { defaultProps: { @@ -351,15 +350,7 @@ export const useCalendarMonth_unstable = ( ref: React.Ref, ): CalendarMonthState => { const baseState = useCalendarMonthBase_unstable(props, ref); - const arrowNavigationAttributes = useArrowNavigationGroup({ - axis: 'grid', - ignoreDefaultKeydown: { - [ArrowDown]: true, - [ArrowLeft]: true, - [ArrowRight]: true, - [ArrowUp]: true, - }, - }); + const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' }); 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 a4512f7d060260..fc3a54f3c629ac 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,17 +2,10 @@ 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'; @@ -24,7 +17,6 @@ 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); @@ -47,21 +39,13 @@ export const useCalendarMonthGridCell_unstable = ( onSelect?.(event); } }, - ...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, - ), + onKeyDown: event => { + nativeProps.onKeyDown?.(event); + if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { + event.preventDefault(); + onSelect(event); + } + }, '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 7c1371f1db40d8..c2b9a925550ae0 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,13 +1,12 @@ 'use client'; import * as React from 'react'; -import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter, Space } from '@fluentui/keyboard-keys'; +import { 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, @@ -20,6 +19,7 @@ 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 Tabster roving focus on top. Base cells own arrow navigation. + * `useCalendarYear_unstable` adds arrow key navigation on top. */ 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 / CALENDAR_GRID_COLUMNS; row++) { + for (let row = 0; row < CELL_COUNT / CELLS_PER_ROW; row++) { const cells: CalendarYearCell[] = []; - for (let column = 0; column < CALENDAR_GRID_COLUMNS; column++) { - const year = fromYear + row * CALENDAR_GRID_COLUMNS + column; + for (let column = 0; column < CELLS_PER_ROW; column++) { + const year = fromYear + row * CELLS_PER_ROW + column; cells.push({ year, content: props.renderYear ? props.renderYear(year) : formatYear(year), @@ -250,15 +250,7 @@ export const useCalendarYear_unstable = ( ref: React.Ref, ): CalendarYearState => { const baseState = useCalendarYearBase_unstable(props, ref); - const arrowNavigationAttributes = useArrowNavigationGroup({ - axis: 'grid', - ignoreDefaultKeydown: { - [ArrowDown]: true, - [ArrowLeft]: true, - [ArrowRight]: true, - [ArrowUp]: true, - }, - }); + const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' }); 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 83657738908b61..3e8cccdc0a7db4 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,17 +2,10 @@ 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'; @@ -24,7 +17,6 @@ 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); @@ -60,21 +52,13 @@ export const useCalendarYearGridCell_unstable = ( onSelect?.(event); } }, - ...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, - ), + onKeyDown: event => { + nativeProps.onKeyDown?.(event); + if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { + event.preventDefault(); + onSelect(event); + } + }, 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 a7eb0b5ab46aa6..994a14d3213935 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,12 +1,5 @@ -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); @@ -17,102 +10,3 @@ export const getCalendarNavigationDate = (event: Event): Date | undefined => { navigationDates.delete(event); return date; }; - -export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => - isHTMLElement(target) && - !!target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); - -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); - -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; - } - - // Native grid owners bubble before React; dispatch here and keep ownership across rerenders. - capturedArrows.add(event.nativeEvent); - onKeyDown(event); - event.preventDefault(); - }, - onKeyDown: event => { - if (!capturedArrows.delete(event.nativeEvent)) { - onKeyDown(event); - } - }, -}); - -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(); - break; - } - } -}; From 1a6e06190d603e4145a0d9862afc93fe3cc80d68 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 14 Oct 2026 21:18:48 +0200 Subject: [PATCH 08/16] fix(react-calendar-preview): preserve focus and grid keyboard ownership Restore the existing Calendar-only focus and keyboard fixes from the saved source snapshot, preserving the source, tests, and specification exactly. Consolidate the related release notes into one scoped patch change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 92cc9ccd-6e8f-4638-8c08-b2323aa950a1 --- ...-8454a189-0723-43f8-81db-d883c01d599a.json | 6 + .../library/docs/Spec.md | 22 +- .../Calendar/Calendar-node.test.tsx | 17 + .../src/components/Calendar/Calendar.cy.tsx | 116 ++- .../Calendar/Calendar.focus.test.tsx | 272 +++++++ .../Calendar/Calendar.gridKeyboard.test.tsx | 708 ++++++++++++++++++ .../src/components/Calendar/useCalendar.tsx | 20 +- .../components/CalendarDay/useCalendarDay.tsx | 4 +- .../useCalendarDayGridCell.ts | 29 +- .../CalendarMonth/useCalendarMonth.tsx | 27 +- .../useCalendarMonthGridCell.ts | 30 +- .../CalendarYear/useCalendarYear.tsx | 22 +- .../useCalendarYearGridCell.ts | 30 +- .../library/src/utils/calendarKeyboard.ts | 106 +++ 14 files changed, 1353 insertions(+), 56 deletions(-) create mode 100644 change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar-node.test.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.focus.test.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx 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..9390a0f8ad51e4 --- /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, and nested focusgroup keyboard ownership", + "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..0e9e9f5e512fb1 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,24 @@ 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. +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. +Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch. + +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 +274,7 @@ The default DOM is equivalent to:
Selected: June 18, 2025
-
+
June 2025
@@ -279,7 +297,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..5631b7df245963 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} + , + ); }; /** @@ -243,6 +247,114 @@ 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(`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( + <> + + + , + ); + 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'); + } + }); + }); + }); + 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( + <> + + + , + ); + 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( + <> + + + , + ); + act(() => getByRole('gridcell', { name: 'March' }).focus()); + rerender( + <> + + + , + ); + 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..9884898728f98c --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.gridKeyboard.test.tsx @@ -0,0 +1,708 @@ +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 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; +}; + +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, ...rowProps } = fixtureProps; + return ( + + + {picker === 'day' ? ( + ({ + onKeyDown: rowProps.onCellKeyDown, + onKeyDownCapture: rowProps.onCellKeyDownCapture, + })} + 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, + }); + } + }); + + 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.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 }) => { + 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('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..72bfbbb9579009 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 @@ -12,7 +12,13 @@ import { 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, + isCalendarArrowKey, + isCalendarGridCellEvent, + isCalendarKeyboardEditable, + setCalendarNavigationDate, +} from '../../utils/calendarKeyboard'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarDayContext_unstable } from '../../contexts/calendarDayContext'; import type { AvailableDateOptions } from '../../utils'; @@ -298,12 +304,21 @@ export const useCalendarDayGridCell_unstable = ( day.onSelected(ev); } }, - onKeyDown: (ev: React.KeyboardEvent) => { - cellProps.onKeyDown?.(ev); - if (!ev.isDefaultPrevented() && !ariaHidden) { - onDayKeyDown(ev); - } - }, + ...getCalendarGridKeyDownHandlers( + ev => { + cellProps.onKeyDown?.(ev); + if ( + !ev.isDefaultPrevented() && + !ariaHidden && + !isCalendarKeyboardEditable(ev.target) && + (!isCalendarArrowKey(ev.key) || isCalendarGridCellEvent(ev)) + ) { + onDayKeyDown(ev); + } + }, + cellProps.onKeyDownCapture, + ev => !ariaHidden && !!originalDate && isCalendarArrowKey(ev.key) && isCalendarGridCellEvent(ev), + ), onMouseDown: !ariaHidden ? mergeCallbacks(cellProps.onMouseDown, onMouseDownDay) : undefined, onMouseOut: !ariaHidden ? mergeCallbacks(cellProps.onMouseOut, onMouseOutDay) : undefined, onMouseOver: !ariaHidden ? mergeCallbacks(cellProps.onMouseOver, onMouseOverDay) : undefined, 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..a4512f7d060260 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,6 +24,7 @@ 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); @@ -39,13 +47,21 @@ export const useCalendarMonthGridCell_unstable = ( onSelect?.(event); } }, - onKeyDown: event => { - nativeProps.onKeyDown?.(event); - if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { - event.preventDefault(); - onSelect(event); - } - }, + ...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, + ), '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..83657738908b61 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); @@ -52,13 +60,21 @@ export const useCalendarYearGridCell_unstable = ( onSelect?.(event); } }, - onKeyDown: event => { - nativeProps.onKeyDown?.(event); - if (!event.isDefaultPrevented() && event.key === Enter && onSelect) { - event.preventDefault(); - onSelect(event); - } - }, + ...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, + ), 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..a7eb0b5ab46aa6 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,102 @@ export const getCalendarNavigationDate = (event: Event): Date | undefined => { navigationDates.delete(event); return date; }; + +export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => + isHTMLElement(target) && + !!target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); + +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); + +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; + } + + // Native grid owners bubble before React; dispatch here and keep ownership across rerenders. + capturedArrows.add(event.nativeEvent); + onKeyDown(event); + event.preventDefault(); + }, + onKeyDown: event => { + if (!capturedArrows.delete(event.nativeEvent)) { + onKeyDown(event); + } + }, +}); + +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(); + break; + } + } +}; From b6eb029392bffb1f21651c2b5ec5ced7eafee897 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 14 Oct 2026 23:55:03 +0200 Subject: [PATCH 09/16] chore(react-calendar-preview): remove duplicate release metadata Restore private:true from master and remove the original initial-release change file from the Calendar source-only PR. Retain the consolidated patch note and the Calendar source, tests, and specification unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 92cc9ccd-6e8f-4638-8c08-b2323aa950a1 --- ...endar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json | 7 ------- .../react-calendar-preview/library/package.json | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json diff --git a/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json b/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json deleted file mode 100644 index 661f914c2a4f67..00000000000000 --- a/change/@fluentui-react-calendar-preview-6cdd6435-805a-4ffc-a584-f5f58769b4da.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "minor", - "comment": "chore: initial release", - "packageName": "@fluentui/react-calendar-preview", - "email": "dmytrokirpa@microsoft.com", - "dependentChangeType": "patch" -} \ No newline at end of file diff --git a/packages/react-components/react-calendar-preview/library/package.json b/packages/react-components/react-calendar-preview/library/package.json index bdf1bc546e79a1..691f19eb1cee5e 100644 --- a/packages/react-components/react-calendar-preview/library/package.json +++ b/packages/react-components/react-calendar-preview/library/package.json @@ -1,6 +1,7 @@ { "name": "@fluentui/react-calendar-preview", "version": "0.0.0", + "private": true, "description": "Fluent UI React Calendar components", "type": "module", "main": "lib-commonjs/index.cjs", From a5cc7661356d7936d285ff33c285e8263f134ebf Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 10:39:38 +0200 Subject: [PATCH 10/16] fix(react-calendar-preview): preserve modified day arrows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../Calendar/Calendar.gridKeyboard.test.tsx | 13 +++++++++++++ .../CalendarDayGridCell/useCalendarDayGridCell.ts | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) 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 index 9884898728f98c..52c3ca8bde4e92 100644 --- 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 @@ -677,6 +677,19 @@ describe.each([ }); 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'); 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 72bfbbb9579009..01bad0719db11f 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 @@ -15,7 +15,7 @@ import { createDate } from '../../utils/dateMath'; import { getCalendarGridKeyDownHandlers, isCalendarArrowKey, - isCalendarGridCellEvent, + isCalendarGridArrow, isCalendarKeyboardEditable, setCalendarNavigationDate, } from '../../utils/calendarKeyboard'; @@ -311,13 +311,13 @@ export const useCalendarDayGridCell_unstable = ( !ev.isDefaultPrevented() && !ariaHidden && !isCalendarKeyboardEditable(ev.target) && - (!isCalendarArrowKey(ev.key) || isCalendarGridCellEvent(ev)) + (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev)) ) { onDayKeyDown(ev); } }, cellProps.onKeyDownCapture, - ev => !ariaHidden && !!originalDate && isCalendarArrowKey(ev.key) && isCalendarGridCellEvent(ev), + ev => !ariaHidden && !!originalDate && isCalendarGridArrow(ev), ), onMouseDown: !ariaHidden ? mergeCallbacks(cellProps.onMouseDown, onMouseDownDay) : undefined, onMouseOut: !ariaHidden ? mergeCallbacks(cellProps.onMouseOut, onMouseOutDay) : undefined, From efdf5414b9f6de34c880a2beb83656f824b55f31 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 11:43:14 +0200 Subject: [PATCH 11/16] fix(react-calendar-preview): honor keyboard cancellations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../library/docs/Spec.md | 11 +- .../Calendar/Calendar.gridKeyboard.test.tsx | 142 +++++++++++++++++- .../useCalendarDayGridCell.ts | 100 ++++++++---- .../useCalendarMonthGridCell.ts | 49 +++--- .../useCalendarYearGridCell.ts | 51 ++++--- .../library/src/utils/calendarKeyboard.ts | 135 +++++++++++++++-- 6 files changed, 406 insertions(+), 82 deletions(-) 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 0e9e9f5e512fb1..55941eae89816c 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -256,11 +256,18 @@ Handled arrows, including boundary keys, are prevented so Tabster or a native fo 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. +cell as `currentTarget`, and `preventDefault()` cancels movement without an intermediate focus hop. Day button +slot capture and bubbling handlers also run before navigation, with the button as `currentTarget`; either can +cancel the built-in action. 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. -Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch. +The native capture fallback preserves this ordering on React 16, whose delegated synthetic capture handlers run +after native grid bubbling listeners. Other keys, including Enter and PageUp/PageDown, retain their normal +bubbling dispatch. + +Editable keyboard detection honors the nearest `contenteditable` boundary: descendants of +`contenteditable="false"` remain navigable even inside an editable ancestor. 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. 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 index 52c3ca8bde4e92..7b02b2e30e4994 100644 --- 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 @@ -27,6 +27,7 @@ import { renderCalendarMonthGridCell_unstable } from '../CalendarMonthGridCell/r import { CalendarYearGridCell } from '../CalendarYearGridCell/CalendarYearGridCell'; import { useCalendarYearGridCell_unstable } from '../CalendarYearGridCell/useCalendarYearGridCell'; import { renderCalendarYearGridCell_unstable } from '../CalendarYearGridCell/renderCalendarYearGridCell'; +import { getCalendarGridKeyDownHandlers, isCalendarGridArrow } 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'; @@ -130,6 +131,8 @@ type FixtureProps = Omit & { allFocusable?: boolean; dir?: 'ltr' | 'rtl'; onGridKeyDownCapture?: React.KeyboardEventHandler; + onButtonKeyDown?: React.KeyboardEventHandler; + onButtonKeyDownCapture?: React.KeyboardEventHandler; }; function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: FixtureProps = {}) { @@ -140,7 +143,14 @@ function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: Fi const onNavigate = jest.fn(); const today = new Date(2020, 8, 18); const element = (fixtureProps: FixtureProps) => { - const { allFocusable = false, dir = 'ltr', onGridKeyDownCapture, ...rowProps } = fixtureProps; + const { + allFocusable = false, + dir = 'ltr', + onGridKeyDownCapture, + onButtonKeyDown, + onButtonKeyDownCapture, + ...rowProps + } = fixtureProps; return ( ({ onKeyDown: rowProps.onCellKeyDown, onKeyDownCapture: rowProps.onCellKeyDownCapture, + button: { + onKeyDown: onButtonKeyDown, + onKeyDownCapture: onButtonKeyDownCapture, + }, })} grid={{ onKeyDownCapture: onGridKeyDownCapture }} body={{ children: }} @@ -236,6 +250,100 @@ describe.each([ } }); + 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(); + }); + }); + + describe('React 16 native capture fallback', () => { + it('runs consumer cancellation before native grid listeners and dispatches once', () => { + const grid = document.createElement('div'); + const cell = document.createElement('button'); + const otherCell = document.createElement('button'); + grid.setAttribute('role', 'grid'); + cell.setAttribute('role', 'gridcell'); + otherCell.setAttribute('role', 'gridcell'); + grid.append(cell, otherCell); + document.body.appendChild(grid); + + const order: string[] = []; + const onCapture = jest.fn((event: React.KeyboardEvent) => { + order.push('capture'); + expect(event.currentTarget).toBe(cell); + }); + const onKeyDown = jest.fn((event: React.KeyboardEvent) => { + order.push('consumer'); + expect(event.currentTarget).toBe(cell); + otherCell.focus(); + }); + const handlers = getCalendarGridKeyDownHandlers(onKeyDown, onCapture, isCalendarGridArrow); + const nativeOwner = jest.fn((event: KeyboardEvent) => { + order.push('native'); + if (!event.defaultPrevented) { + otherCell.focus(); + } + }); + cell.addEventListener('keydown', handlers.onLegacyKeyDownCapture, true); + grid.addEventListener('keydown', nativeOwner); + + try { + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); + cell.dispatchEvent(event); + + expect(order).toEqual(['capture', 'consumer', 'native']); + expect(onCapture).toHaveBeenCalledTimes(1); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(nativeOwner).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + expect(otherCell).toHaveFocus(); + } finally { + cell.removeEventListener('keydown', handlers.onLegacyKeyDownCapture, true); + grid.removeEventListener('keydown', nativeOwner); + grid.remove(); + } + }); + + it('does not run built-in navigation after native capture cancellation', () => { + const grid = document.createElement('div'); + const cell = document.createElement('button'); + grid.setAttribute('role', 'grid'); + cell.setAttribute('role', 'gridcell'); + grid.appendChild(cell); + document.body.appendChild(grid); + const onCapture = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); + const onKeyDown = jest.fn(); + const handlers = getCalendarGridKeyDownHandlers(onKeyDown, onCapture, isCalendarGridArrow); + cell.addEventListener('keydown', handlers.onLegacyKeyDownCapture, true); + + try { + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); + cell.dispatchEvent(event); + + expect(onCapture).toHaveBeenCalledTimes(1); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + } finally { + cell.removeEventListener('keydown', handlers.onLegacyKeyDownCapture, true); + grid.remove(); + } + }); + }); + it.each([ { key: 'ArrowRight', target: 6 }, { key: 'ArrowLeft', target: 4 }, @@ -390,6 +498,38 @@ describe.each([ { 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.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(phase === 'capture' ? 0 : 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(); 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 01bad0719db11f..89538b1151f150 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,12 +1,13 @@ '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, mergeCallbacks, slot, + useIsomorphicLayoutEffect, useMergedRefs, } from '@fluentui/react-utilities'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; @@ -14,6 +15,7 @@ import { compareDatePart, findAvailableDate, stringifyDataAttribute } from '../. import { createDate } from '../../utils/dateMath'; import { getCalendarGridKeyDownHandlers, + invokeCalendarKeyboardHandler, isCalendarArrowKey, isCalendarGridArrow, isCalendarKeyboardEditable, @@ -35,6 +37,7 @@ 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 dispatchedButtonEvents = new WeakSet(); const applyCorners = (element: HTMLElement, corners: DayCorners): void => { element.toggleAttribute('data-corner-top-left', corners.topLeft); @@ -99,7 +102,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; @@ -277,6 +279,19 @@ 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 buttonOnKeyDownCapture = button.onKeyDownCapture; + const buttonOnKeyDown = button.onKeyDown; + const isFocusable = !ariaHidden && !!originalDate && (allFocusable || (day.isInBounds ? true : undefined)); const setCellRef = (element: HTMLTableCellElement) => { @@ -290,7 +305,58 @@ export const useCalendarDayGridCell_unstable = ( * The grid publishes `navigatedDayRef` so it can focus the navigated cell; assigning it from this * ref callback runs at commit, not during render. */ - const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref); + const cellElementRef = React.useRef(null); + const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref, cellElementRef); + const keyDownHandlers = getCalendarGridKeyDownHandlers( + ev => { + if (isCalendarGridArrow(ev)) { + const buttonElement = ev.currentTarget.querySelector('button'); + if (buttonElement) { + dispatchedButtonEvents.add(ev.nativeEvent); + invokeCalendarKeyboardHandler(ev, buttonElement, buttonOnKeyDownCapture); + if (!ev.isDefaultPrevented() && !ev.isPropagationStopped()) { + invokeCalendarKeyboardHandler(ev, buttonElement, buttonOnKeyDown); + } + } + } + if (!ev.isPropagationStopped()) { + cellProps.onKeyDown?.(ev); + } + if ( + !ev.isDefaultPrevented() && + !ev.isPropagationStopped() && + !ariaHidden && + !isCalendarKeyboardEditable(ev.target) && + (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev)) + ) { + onDayKeyDown(ev); + } + }, + cellProps.onKeyDownCapture, + ev => !ariaHidden && !!originalDate && isCalendarGridArrow(ev), + ); + + useIsomorphicLayoutEffect(() => { + const cell = cellElementRef.current; + if (!cell) { + return; + } + const listener = keyDownHandlers.onLegacyKeyDownCapture; + cell.addEventListener('keydown', listener, true); + return () => cell.removeEventListener('keydown', listener, true); + }, [keyDownHandlers]); + + button.onKeyDownCapture = ev => { + if (!dispatchedButtonEvents.has(ev.nativeEvent)) { + buttonOnKeyDownCapture?.(ev); + } + }; + button.onKeyDown = ev => { + if (!dispatchedButtonEvents.has(ev.nativeEvent)) { + buttonOnKeyDown?.(ev); + } + }; + const root = slot.always>>( getIntrinsicElementProps('td', { ...cellProps, @@ -304,21 +370,8 @@ export const useCalendarDayGridCell_unstable = ( day.onSelected(ev); } }, - ...getCalendarGridKeyDownHandlers( - ev => { - cellProps.onKeyDown?.(ev); - if ( - !ev.isDefaultPrevented() && - !ariaHidden && - !isCalendarKeyboardEditable(ev.target) && - (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev)) - ) { - onDayKeyDown(ev); - } - }, - cellProps.onKeyDownCapture, - ev => !ariaHidden && !!originalDate && isCalendarGridArrow(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, @@ -358,16 +411,7 @@ 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', - }), + button, dayLabel: slot.always(cellProps.dayLabel, { defaultProps: { children: originalDate ? formatters.dateTime({ date: originalDate, format: 'day' }) : day.date, 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 a4512f7d060260..903e19bea075a4 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 @@ -1,9 +1,9 @@ 'use client'; -import type * as React from 'react'; +import * 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 { getIntrinsicElementProps, slot, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarMonthContext_unstable } from '../../contexts/calendarMonthContext'; import { stringifyDataAttribute } from '../../utils'; @@ -29,7 +29,33 @@ export const useCalendarMonthGridCell_unstable = ( 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 rootElementRef = React.useRef(null); + const rootRef = useMergedRefs(ref, month.isNavigated ? navigatedMonthRef : undefined, rootElementRef); + 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, + ); + + useIsomorphicLayoutEffect(() => { + const cell = rootElementRef.current; + if (!cell) { + return; + } + const listener = keyDownHandlers.onLegacyKeyDownCapture; + cell.addEventListener('keydown', listener, true); + return () => cell.removeEventListener('keydown', listener, true); + }, [keyDownHandlers]); const root = slot.always( { @@ -47,21 +73,8 @@ export const useCalendarMonthGridCell_unstable = ( onSelect?.(event); } }, - ...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, - ), + 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/CalendarYearGridCell/useCalendarYearGridCell.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts index 83657738908b61..67363c0de63115 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 @@ -1,9 +1,9 @@ 'use client'; -import type * as React from 'react'; +import * 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 { getIntrinsicElementProps, slot, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarYearContext_unstable } from '../../contexts/calendarYearContext'; import { stringifyDataAttribute } from '../../utils'; @@ -37,6 +37,8 @@ export const useCalendarYearGridCell_unstable = ( cell.isCurrent ? currentYearRef : undefined, cell.isNavigated ? navigatedYearRef : undefined, ); + const rootElementRef = React.useRef(null); + const rootRef = useMergedRefs(cellRef, rootElementRef); const onSelect = cell.isDisabled ? undefined @@ -47,10 +49,36 @@ 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, + isCalendarGridArrow, + ); + + useIsomorphicLayoutEffect(() => { + const cellElement = rootElementRef.current; + if (!cellElement) { + return; + } + const listener = keyDownHandlers.onLegacyKeyDownCapture; + cellElement.addEventListener('keydown', listener, true); + return () => cellElement.removeEventListener('keydown', listener, true); + }, [keyDownHandlers]); + const root = slot.always>>( { ...nativeProps, - ref: cellRef, + ref: rootRef, children: cell.content, type: 'button', role: 'gridcell', @@ -60,21 +88,8 @@ export const useCalendarYearGridCell_unstable = ( onSelect?.(event); } }, - ...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, - ), + 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 a7eb0b5ab46aa6..0627a13ebb4321 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 @@ -7,6 +7,8 @@ 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(); +const legacyHandledArrows = new WeakSet(); +const reactCaptureEvents = new WeakSet(); export const setCalendarNavigationDate = (event: Event, date: Date): void => { navigationDates.set(event, date); @@ -18,9 +20,18 @@ export const getCalendarNavigationDate = (event: Event): Date | undefined => { return date; }; -export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => - isHTMLElement(target) && - !!target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); +export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean => { + if (!isHTMLElement(target)) { + return false; + } + + if (target.closest('input, textarea, select')) { + return true; + } + + const contentEditableBoundary = target.closest('[contenteditable]'); + return contentEditableBoundary?.getAttribute('contenteditable') !== 'false' && !!contentEditableBoundary; +}; export const isCalendarArrowKey = (key: string): boolean => key === ArrowDown || key === ArrowLeft || key === ArrowRight || key === ArrowUp; @@ -44,28 +55,122 @@ export const isCalendarGridArrow = (event: React.KeyboardEvent): bo !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; + +const fromNativeKeyboardEvent = ( + nativeEvent: KeyboardEvent, + currentTarget: T, +): React.KeyboardEvent => { + let propagationStopped = false; + return new Proxy(nativeEvent, { + get: (target, property) => { + if (property === 'currentTarget') { + return currentTarget; + } + if (property === 'nativeEvent') { + return nativeEvent; + } + if (property === 'isDefaultPrevented') { + return () => nativeEvent.defaultPrevented; + } + if (property === 'isPropagationStopped') { + return () => propagationStopped; + } + if (property === 'persist') { + return () => undefined; + } + if (property === 'preventDefault') { + return () => nativeEvent.preventDefault(); + } + if (property === 'stopPropagation') { + return () => { + propagationStopped = true; + nativeEvent.stopPropagation(); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as unknown as React.KeyboardEvent; +}; + export const getCalendarGridKeyDownHandlers = ( onKeyDown: React.KeyboardEventHandler, onKeyDownCapture: React.KeyboardEventHandler | undefined, ownsArrow: (event: React.KeyboardEvent) => boolean, -): Required, 'onKeyDown' | 'onKeyDownCapture'>> => ({ - onKeyDownCapture: event => { +): Required, 'onKeyDown' | 'onKeyDownCapture'>> & { + onLegacyKeyDownCapture: (event: KeyboardEvent) => void; +} => { + const onLegacyKeyDownCapture = (nativeEvent: KeyboardEvent): void => { + if ( + capturedArrows.has(nativeEvent) || + reactCaptureEvents.has(nativeEvent) || + !isCalendarArrowKey(nativeEvent.key) + ) { + return; + } + + const event = fromNativeKeyboardEvent(nativeEvent, nativeEvent.currentTarget as T); + if (!ownsArrow(event)) { + return; + } + + legacyHandledArrows.add(nativeEvent); onKeyDownCapture?.(event); - if (event.isPropagationStopped() || !ownsArrow(event)) { + if (event.isPropagationStopped()) { return; } - // Native grid owners bubble before React; dispatch here and keep ownership across rerenders. - capturedArrows.add(event.nativeEvent); - onKeyDown(event); - event.preventDefault(); - }, - onKeyDown: event => { - if (!capturedArrows.delete(event.nativeEvent)) { + if (!event.isDefaultPrevented()) { onKeyDown(event); } - }, -}); + event.preventDefault(); + }; + + return { + onKeyDownCapture: event => { + if (legacyHandledArrows.has(event.nativeEvent)) { + return; + } + if (isCalendarArrowKey(event.key)) { + reactCaptureEvents.add(event.nativeEvent); + } + onKeyDownCapture?.(event); + if (event.isPropagationStopped() || !ownsArrow(event)) { + return; + } + + // Native grid owners bubble before React 16 dispatches delegated handlers. + capturedArrows.add(event.nativeEvent); + onKeyDown(event); + event.preventDefault(); + }, + onKeyDown: event => { + if (legacyHandledArrows.delete(event.nativeEvent)) { + return; + } + reactCaptureEvents.delete(event.nativeEvent); + if (!capturedArrows.delete(event.nativeEvent)) { + onKeyDown(event); + } + }, + onLegacyKeyDownCapture, + }; +}; + +export const invokeCalendarKeyboardHandler = ( + event: React.KeyboardEvent, + currentTarget: T, + handler: React.KeyboardEventHandler | undefined, +): void => { + handler?.(withCurrentTarget(event, currentTarget)); +}; export const handleCalendarGridKeyDown = ( event: React.KeyboardEvent, From d19781602caef3a9cdf3a3dc1711aab88b41330d Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 11:44:41 +0200 Subject: [PATCH 12/16] test(react-calendar-preview): verify legacy event deduplication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../Calendar/Calendar.gridKeyboard.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 7b02b2e30e4994..6f6b8beca5f9de 100644 --- 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 @@ -305,6 +305,18 @@ describe.each([ const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); cell.dispatchEvent(event); + const delegatedEvent = { + currentTarget: cell, + isDefaultPrevented: () => event.defaultPrevented, + isPropagationStopped: () => false, + key: 'ArrowRight', + nativeEvent: event, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + } as unknown as React.KeyboardEvent; + handlers.onKeyDownCapture(delegatedEvent); + handlers.onKeyDown(delegatedEvent); + expect(order).toEqual(['capture', 'consumer', 'native']); expect(onCapture).toHaveBeenCalledTimes(1); expect(onKeyDown).toHaveBeenCalledTimes(1); From 19f1c9215650caeb72ff4ef0cc9a79a176a16520 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 12:16:17 +0200 Subject: [PATCH 13/16] fix(react-calendar-preview): preserve keyboard event paths Skip unfocusable grid candidates, preserve consumer propagation semantics, and remove the React 16 native event bridge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- ...-8454a189-0723-43f8-81db-d883c01d599a.json | 2 +- .../library/docs/Spec.md | 14 +- .../src/components/Calendar/Calendar.cy.tsx | 62 ++++++ .../Calendar/Calendar.gridKeyboard.test.tsx | 199 ++++++++++-------- .../useCalendarDayGridCell.ts | 141 +++++++------ .../useCalendarMonthGridCell.ts | 17 +- .../useCalendarYearGridCell.ts | 18 +- .../library/src/utils/calendarKeyboard.ts | 108 ++-------- 8 files changed, 286 insertions(+), 275 deletions(-) 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 index 9390a0f8ad51e4..41b3103ebdd3b1 100644 --- a/change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json +++ b/change/@fluentui-react-calendar-preview-8454a189-0723-43f8-81db-d883c01d599a.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: restore class-independent Calendar focus, two-dimensional grid arrows, and nested focusgroup keyboard ownership", + "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 55941eae89816c..d7dbbb3df50a87 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -257,14 +257,18 @@ Handled arrows, including boundary keys, are prevented so Tabster or a native fo 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 button -slot capture and bubbling handlers also run before navigation, with the button as `currentTarget`; either can -cancel the built-in action. +slot capture and bubbling handlers also run before navigation, with the slot element as `currentTarget`. +Only handlers on the target's ancestor path run, in capture then bubbling order. `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. -The native capture fallback preserves this ordering on React 16, whose delegated synthetic capture handlers run -after native grid bubbling listeners. Other keys, including Enter and PageUp/PageDown, retain their normal -bubbling dispatch. +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. 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 5631b7df245963..023f41af1208e2 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 @@ -37,6 +37,58 @@ const goToToday = `.${calendarClassNames.goToTodayButton}`; describe('Calendar', () => { describe('day grid keyboard navigation', () => { + 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'); + }); + it('pages from the arrow-focused day rather than the original anchor', () => { const onDisplayedDateChange = cy.stub().as('onDisplayedDateChange'); mountFluent(); @@ -255,6 +307,16 @@ describe('Calendar', () => { } }; + 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'); 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 index 6f6b8beca5f9de..934a88ede3ba10 100644 --- 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 @@ -27,7 +27,6 @@ import { renderCalendarMonthGridCell_unstable } from '../CalendarMonthGridCell/r import { CalendarYearGridCell } from '../CalendarYearGridCell/CalendarYearGridCell'; import { useCalendarYearGridCell_unstable } from '../CalendarYearGridCell/useCalendarYearGridCell'; import { renderCalendarYearGridCell_unstable } from '../CalendarYearGridCell/renderCalendarYearGridCell'; -import { getCalendarGridKeyDownHandlers, isCalendarGridArrow } 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'; @@ -133,6 +132,8 @@ type FixtureProps = Omit & { 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 = {}) { @@ -149,6 +150,8 @@ function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: Fi onGridKeyDownCapture, onButtonKeyDown, onButtonKeyDownCapture, + dayLabel, + marker, ...rowProps } = fixtureProps; return ( @@ -167,6 +170,8 @@ function renderPicker(picker: 'day' | 'month' | 'year', base: boolean, props: Fi onKeyDown: onButtonKeyDown, onKeyDownCapture: onButtonKeyDownCapture, }, + dayLabel, + marker, })} grid={{ onKeyDownCapture: onGridKeyDownCapture }} body={{ children: }} @@ -270,92 +275,6 @@ describe.each([ }); }); - describe('React 16 native capture fallback', () => { - it('runs consumer cancellation before native grid listeners and dispatches once', () => { - const grid = document.createElement('div'); - const cell = document.createElement('button'); - const otherCell = document.createElement('button'); - grid.setAttribute('role', 'grid'); - cell.setAttribute('role', 'gridcell'); - otherCell.setAttribute('role', 'gridcell'); - grid.append(cell, otherCell); - document.body.appendChild(grid); - - const order: string[] = []; - const onCapture = jest.fn((event: React.KeyboardEvent) => { - order.push('capture'); - expect(event.currentTarget).toBe(cell); - }); - const onKeyDown = jest.fn((event: React.KeyboardEvent) => { - order.push('consumer'); - expect(event.currentTarget).toBe(cell); - otherCell.focus(); - }); - const handlers = getCalendarGridKeyDownHandlers(onKeyDown, onCapture, isCalendarGridArrow); - const nativeOwner = jest.fn((event: KeyboardEvent) => { - order.push('native'); - if (!event.defaultPrevented) { - otherCell.focus(); - } - }); - cell.addEventListener('keydown', handlers.onLegacyKeyDownCapture, true); - grid.addEventListener('keydown', nativeOwner); - - try { - const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); - cell.dispatchEvent(event); - - const delegatedEvent = { - currentTarget: cell, - isDefaultPrevented: () => event.defaultPrevented, - isPropagationStopped: () => false, - key: 'ArrowRight', - nativeEvent: event, - preventDefault: () => event.preventDefault(), - stopPropagation: () => event.stopPropagation(), - } as unknown as React.KeyboardEvent; - handlers.onKeyDownCapture(delegatedEvent); - handlers.onKeyDown(delegatedEvent); - - expect(order).toEqual(['capture', 'consumer', 'native']); - expect(onCapture).toHaveBeenCalledTimes(1); - expect(onKeyDown).toHaveBeenCalledTimes(1); - expect(nativeOwner).toHaveBeenCalledTimes(1); - expect(event.defaultPrevented).toBe(true); - expect(otherCell).toHaveFocus(); - } finally { - cell.removeEventListener('keydown', handlers.onLegacyKeyDownCapture, true); - grid.removeEventListener('keydown', nativeOwner); - grid.remove(); - } - }); - - it('does not run built-in navigation after native capture cancellation', () => { - const grid = document.createElement('div'); - const cell = document.createElement('button'); - grid.setAttribute('role', 'grid'); - cell.setAttribute('role', 'gridcell'); - grid.appendChild(cell); - document.body.appendChild(grid); - const onCapture = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); - const onKeyDown = jest.fn(); - const handlers = getCalendarGridKeyDownHandlers(onKeyDown, onCapture, isCalendarGridArrow); - cell.addEventListener('keydown', handlers.onLegacyKeyDownCapture, true); - - try { - const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); - cell.dispatchEvent(event); - - expect(onCapture).toHaveBeenCalledTimes(1); - expect(onKeyDown).not.toHaveBeenCalled(); - expect(event.defaultPrevented).toBe(true); - } finally { - cell.removeEventListener('keydown', handlers.onLegacyKeyDownCapture, true); - grid.remove(); - } - }); - }); - it.each([ { key: 'ArrowRight', target: 6 }, { key: 'ArrowLeft', target: 4 }, @@ -372,6 +291,20 @@ describe.each([ 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', target: 4 }, { key: 'ArrowLeft', target: 6 }, @@ -511,6 +444,96 @@ describe.each([ { 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') { @@ -534,7 +557,7 @@ describe.each([ expect(button).toHaveFocus(); expect(onNavigate).not.toHaveBeenCalled(); expect(onButtonKeyDownCapture).toHaveBeenCalledTimes(1); - expect(onButtonKeyDown).toHaveBeenCalledTimes(phase === 'capture' ? 0 : 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); 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 89538b1151f150..516f12d92e248f 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 @@ -5,9 +5,10 @@ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Enter, PageDown, PageUp, Spa import { getIntrinsicElementProps, getRTLSafeKey, + isHTMLElement, mergeCallbacks, slot, - useIsomorphicLayoutEffect, + useEventCallback, useMergedRefs, } from '@fluentui/react-utilities'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; @@ -37,7 +38,28 @@ 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 dispatchedButtonEvents = new WeakSet(); +const dispatchedSlotEvents = new WeakSet(); + +const forwardSlotKeyboardHandlers = ( + slotProps: Pick, 'onKeyDown' | 'onKeyDownCapture'>, +) => { + const { onKeyDown, onKeyDownCapture } = slotProps; + slotProps.onKeyDownCapture = event => { + if (!dispatchedSlotEvents.has(event.nativeEvent)) { + onKeyDownCapture?.(event); + } + }; + slotProps.onKeyDown = event => { + if (!dispatchedSlotEvents.has(event.nativeEvent)) { + onKeyDown?.(event); + } + }; + return (event: React.KeyboardEvent, capture: boolean, element: T | null) => { + if (element && isHTMLElement(event.target) && element.contains(event.target)) { + invokeCalendarKeyboardHandler(event, element, capture ? onKeyDownCapture : onKeyDown); + } + }; +}; const applyCorners = (element: HTMLElement, corners: DayCorners): void => { element.toggleAttribute('data-corner-top-left', corners.topLeft); @@ -289,8 +311,29 @@ export const useCalendarDayGridCell_unstable = ( }, elementType: 'button', }); - const buttonOnKeyDownCapture = button.onKeyDownCapture; - const buttonOnKeyDown = button.onKeyDown; + 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)); @@ -305,57 +348,46 @@ export const useCalendarDayGridCell_unstable = ( * The grid publishes `navigatedDayRef` so it can focus the navigated cell; assigning it from this * ref callback runs at commit, not during render. */ - const cellElementRef = React.useRef(null); - const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref, cellElementRef); - const keyDownHandlers = getCalendarGridKeyDownHandlers( - ev => { - if (isCalendarGridArrow(ev)) { - const buttonElement = ev.currentTarget.querySelector('button'); - if (buttonElement) { - dispatchedButtonEvents.add(ev.nativeEvent); - invokeCalendarKeyboardHandler(ev, buttonElement, buttonOnKeyDownCapture); - if (!ev.isDefaultPrevented() && !ev.isPropagationStopped()) { - invokeCalendarKeyboardHandler(ev, buttonElement, buttonOnKeyDown); - } + const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref); + const onKeyDown = useEventCallback((ev: React.KeyboardEvent) => { + if (isCalendarGridArrow(ev)) { + dispatchedSlotEvents.add(ev.nativeEvent); + 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); } } - if (!ev.isPropagationStopped()) { - cellProps.onKeyDown?.(ev); + for (const forward of slotHandlers.reverse()) { + if (!ev.isPropagationStopped()) { + forward(false); + } } - if ( - !ev.isDefaultPrevented() && - !ev.isPropagationStopped() && - !ariaHidden && - !isCalendarKeyboardEditable(ev.target) && - (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev)) - ) { - onDayKeyDown(ev); + if (ev.isPropagationStopped()) { + return; } - }, - cellProps.onKeyDownCapture, - ev => !ariaHidden && !!originalDate && isCalendarGridArrow(ev), - ); - - useIsomorphicLayoutEffect(() => { - const cell = cellElementRef.current; - if (!cell) { - return; } - const listener = keyDownHandlers.onLegacyKeyDownCapture; - cell.addEventListener('keydown', listener, true); - return () => cell.removeEventListener('keydown', listener, true); - }, [keyDownHandlers]); - - button.onKeyDownCapture = ev => { - if (!dispatchedButtonEvents.has(ev.nativeEvent)) { - buttonOnKeyDownCapture?.(ev); + if (!ev.isPropagationStopped()) { + cellProps.onKeyDown?.(ev); } - }; - button.onKeyDown = ev => { - if (!dispatchedButtonEvents.has(ev.nativeEvent)) { - buttonOnKeyDown?.(ev); + if ( + !ev.isDefaultPrevented() && + !ariaHidden && + !isCalendarKeyboardEditable(ev.target) && + (!isCalendarArrowKey(ev.key) || isCalendarGridArrow(ev)) + ) { + onDayKeyDown(ev); } - }; + }); + const keyDownHandlers = getCalendarGridKeyDownHandlers( + onKeyDown, + cellProps.onKeyDownCapture, + ev => !ariaHidden && !!originalDate && isCalendarGridArrow(ev), + ); const root = slot.always>>( getIntrinsicElementProps('td', { @@ -412,16 +444,7 @@ export const useCalendarDayGridCell_unstable = ( }, root, 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', - }), + dayLabel, + marker, }; }; 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 903e19bea075a4..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 @@ -1,9 +1,9 @@ 'use client'; -import * as React from 'react'; +import type * as React from 'react'; import { Enter } from '@fluentui/keyboard-keys'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; -import { getIntrinsicElementProps, slot, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, slot, useMergedRefs } from '@fluentui/react-utilities'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarMonthContext_unstable } from '../../contexts/calendarMonthContext'; import { stringifyDataAttribute } from '../../utils'; @@ -29,8 +29,7 @@ export const useCalendarMonthGridCell_unstable = ( const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable); const navigatedMonthRef = useCalendarMonthContext_unstable(ctx => ctx.navigatedMonthRef); const onSelect = month.isInBounds ? month.onSelect : undefined; - const rootElementRef = React.useRef(null); - const rootRef = useMergedRefs(ref, month.isNavigated ? navigatedMonthRef : undefined, rootElementRef); + const rootRef = useMergedRefs(ref, month.isNavigated ? navigatedMonthRef : undefined); const keyDownHandlers = getCalendarGridKeyDownHandlers( event => { nativeProps.onKeyDown?.(event); @@ -47,16 +46,6 @@ export const useCalendarMonthGridCell_unstable = ( isCalendarGridArrow, ); - useIsomorphicLayoutEffect(() => { - const cell = rootElementRef.current; - if (!cell) { - return; - } - const listener = keyDownHandlers.onLegacyKeyDownCapture; - cell.addEventListener('keydown', listener, true); - return () => cell.removeEventListener('keydown', listener, true); - }, [keyDownHandlers]); - const root = slot.always( { ...nativeProps, 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 67363c0de63115..d8581eff036187 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 @@ -1,9 +1,9 @@ 'use client'; -import * as React from 'react'; +import type * as React from 'react'; import { Enter } from '@fluentui/keyboard-keys'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; -import { getIntrinsicElementProps, slot, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, slot, useMergedRefs } from '@fluentui/react-utilities'; import { useCalendarContext_unstable } from '../../contexts/calendarContext'; import { useCalendarYearContext_unstable } from '../../contexts/calendarYearContext'; import { stringifyDataAttribute } from '../../utils'; @@ -37,8 +37,6 @@ export const useCalendarYearGridCell_unstable = ( cell.isCurrent ? currentYearRef : undefined, cell.isNavigated ? navigatedYearRef : undefined, ); - const rootElementRef = React.useRef(null); - const rootRef = useMergedRefs(cellRef, rootElementRef); const onSelect = cell.isDisabled ? undefined @@ -65,20 +63,10 @@ export const useCalendarYearGridCell_unstable = ( isCalendarGridArrow, ); - useIsomorphicLayoutEffect(() => { - const cellElement = rootElementRef.current; - if (!cellElement) { - return; - } - const listener = keyDownHandlers.onLegacyKeyDownCapture; - cellElement.addEventListener('keydown', listener, true); - return () => cellElement.removeEventListener('keydown', listener, true); - }, [keyDownHandlers]); - const root = slot.always>>( { ...nativeProps, - ref: rootRef, + ref: cellRef, children: cell.content, type: 'button', role: 'gridcell', 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 0627a13ebb4321..33e729a92f6970 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 @@ -7,8 +7,6 @@ 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(); -const legacyHandledArrows = new WeakSet(); -const reactCaptureEvents = new WeakSet(); export const setCalendarNavigationDate = (event: Event, date: Date): void => { navigationDates.set(event, date); @@ -63,106 +61,28 @@ const withCurrentTarget = ( get: (target, property) => (property === 'currentTarget' ? currentTarget : Reflect.get(target, property, target)), }) as React.KeyboardEvent; -const fromNativeKeyboardEvent = ( - nativeEvent: KeyboardEvent, - currentTarget: T, -): React.KeyboardEvent => { - let propagationStopped = false; - return new Proxy(nativeEvent, { - get: (target, property) => { - if (property === 'currentTarget') { - return currentTarget; - } - if (property === 'nativeEvent') { - return nativeEvent; - } - if (property === 'isDefaultPrevented') { - return () => nativeEvent.defaultPrevented; - } - if (property === 'isPropagationStopped') { - return () => propagationStopped; - } - if (property === 'persist') { - return () => undefined; - } - if (property === 'preventDefault') { - return () => nativeEvent.preventDefault(); - } - if (property === 'stopPropagation') { - return () => { - propagationStopped = true; - nativeEvent.stopPropagation(); - }; - } - const value = Reflect.get(target, property, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) as unknown as React.KeyboardEvent; -}; - export const getCalendarGridKeyDownHandlers = ( onKeyDown: React.KeyboardEventHandler, onKeyDownCapture: React.KeyboardEventHandler | undefined, ownsArrow: (event: React.KeyboardEvent) => boolean, -): Required, 'onKeyDown' | 'onKeyDownCapture'>> & { - onLegacyKeyDownCapture: (event: KeyboardEvent) => void; -} => { - const onLegacyKeyDownCapture = (nativeEvent: KeyboardEvent): void => { - if ( - capturedArrows.has(nativeEvent) || - reactCaptureEvents.has(nativeEvent) || - !isCalendarArrowKey(nativeEvent.key) - ) { - return; - } - - const event = fromNativeKeyboardEvent(nativeEvent, nativeEvent.currentTarget as T); - if (!ownsArrow(event)) { - return; - } - - legacyHandledArrows.add(nativeEvent); +): Required, 'onKeyDown' | 'onKeyDownCapture'>> => ({ + onKeyDownCapture: event => { onKeyDownCapture?.(event); - if (event.isPropagationStopped()) { + if (event.isPropagationStopped() || !ownsArrow(event)) { return; } - if (!event.isDefaultPrevented()) { - onKeyDown(event); - } + // Dispatch before native grid bubbling, then suppress the later synthetic bubble. + capturedArrows.add(event.nativeEvent); + onKeyDown(event); event.preventDefault(); - }; - - return { - onKeyDownCapture: event => { - if (legacyHandledArrows.has(event.nativeEvent)) { - return; - } - if (isCalendarArrowKey(event.key)) { - reactCaptureEvents.add(event.nativeEvent); - } - onKeyDownCapture?.(event); - if (event.isPropagationStopped() || !ownsArrow(event)) { - return; - } - - // Native grid owners bubble before React 16 dispatches delegated handlers. - capturedArrows.add(event.nativeEvent); + }, + onKeyDown: event => { + if (!capturedArrows.delete(event.nativeEvent)) { onKeyDown(event); - event.preventDefault(); - }, - onKeyDown: event => { - if (legacyHandledArrows.delete(event.nativeEvent)) { - return; - } - reactCaptureEvents.delete(event.nativeEvent); - if (!capturedArrows.delete(event.nativeEvent)) { - onKeyDown(event); - } - }, - onLegacyKeyDownCapture, - }; -}; + } + }, +}); export const invokeCalendarKeyboardHandler = ( event: React.KeyboardEvent, @@ -217,7 +137,9 @@ export const handleCalendarGridKeyDown = ( const cell = cells[index]; if (!cell.matches(':disabled') && (allFocusable || cell.getAttribute('aria-disabled') !== 'true')) { cell.focus(); - break; + if (cell.ownerDocument.activeElement === cell) { + break; + } } } }; From 462400a8d999ebcda863279ef2f3fbbb63f66447 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 14:19:13 +0200 Subject: [PATCH 14/16] fix(react-calendar-preview): honor nested keyboard events Handle shadow-root focus targets, native contenteditable inheritance, and key handlers on custom day content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../library/docs/Spec.md | 9 ++-- .../src/components/Calendar/Calendar.cy.tsx | 41 ++++++++++++++ .../Calendar/Calendar.gridKeyboard.test.tsx | 54 +++++++++++++++++++ .../useCalendarDayGridCell.ts | 48 +++++++++++------ .../library/src/utils/calendarKeyboard.ts | 27 ++++++++-- 5 files changed, 155 insertions(+), 24 deletions(-) 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 d7dbbb3df50a87..d5769b7d37ec28 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -256,9 +256,9 @@ Handled arrows, including boundary keys, are prevented so Tabster or a native fo 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 button -slot capture and bubbling handlers also run before navigation, with the slot element as `currentTarget`. -Only handlers on the target's ancestor path run, in capture then bubbling order. `preventDefault()` cancels +cell as `currentTarget`, and `preventDefault()` cancels movement without an intermediate focus hop. Day button and day-label slot handlers, including handlers on custom descendants, run in natural capture and +bubbling order 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"`, @@ -271,7 +271,8 @@ Month/year navigation continues past cells that cannot receive focus, including 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. +`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. 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 023f41af1208e2..e50a896dd316d8 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 @@ -89,6 +89,47 @@ describe('Calendar', () => { 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(); 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 index 934a88ede3ba10..cba0d63bfee4bb 100644 --- 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 @@ -27,6 +27,7 @@ import { renderCalendarMonthGridCell_unstable } from '../CalendarMonthGridCell/r 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'; @@ -275,6 +276,18 @@ describe.each([ }); }); + 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 }, @@ -305,6 +318,47 @@ describe.each([ 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 }, 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 516f12d92e248f..b5df59531681c4 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 @@ -351,24 +351,28 @@ export const useCalendarDayGridCell_unstable = ( const cellRef = useMergedRefs(setCellRef, cellProps.ref, ref); const onKeyDown = useEventCallback((ev: React.KeyboardEvent) => { if (isCalendarGridArrow(ev)) { - dispatchedSlotEvents.add(ev.nativeEvent); - 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); + const isSlotRootTarget = + ev.target === buttonRef.current || ev.target === dayLabelRef.current || ev.target === markerRef.current; + if (isSlotRootTarget) { + dispatchedSlotEvents.add(ev.nativeEvent); + 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); + for (const forward of slotHandlers.reverse()) { + if (!ev.isPropagationStopped()) { + forward(false); + } + } + if (ev.isPropagationStopped()) { + return; } - } - if (ev.isPropagationStopped()) { - return; } } if (!ev.isPropagationStopped()) { @@ -383,10 +387,20 @@ export const useCalendarDayGridCell_unstable = ( 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, - ev => !ariaHidden && !!originalDate && isCalendarGridArrow(ev), + ownsArrow, ); const root = slot.always>>( 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 33e729a92f6970..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 @@ -27,8 +27,29 @@ export const isCalendarKeyboardEditable = (target: EventTarget | null): boolean return true; } - const contentEditableBoundary = target.closest('[contenteditable]'); - return contentEditableBoundary?.getAttribute('contenteditable') !== 'false' && !!contentEditableBoundary; + 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 => @@ -137,7 +158,7 @@ export const handleCalendarGridKeyDown = ( const cell = cells[index]; if (!cell.matches(':disabled') && (allFocusable || cell.getAttribute('aria-disabled') !== 'true')) { cell.focus(); - if (cell.ownerDocument.activeElement === cell) { + if (getCalendarRootActiveElement(cell) === cell) { break; } } From 8bf771560aceb32e4b46a13cf710d01f3a45258b Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 14:58:39 +0200 Subject: [PATCH 15/16] fix(react-calendar-preview): scope keyboard dedup to slots Keep nested Calendar keyboard events from suppressing enclosing day-slot callbacks, and cover bubbling with a nested Calendar regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../src/components/Calendar/Calendar.cy.tsx | 28 +++++++++++++++++++ .../useCalendarDayGridCell.ts | 20 ++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) 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 e50a896dd316d8..6946defd9ba87e 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 @@ -37,6 +37,34 @@ 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( 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 b5df59531681c4..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 @@ -38,24 +38,37 @@ 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 dispatchedSlotEvents = new WeakSet(); +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 (!dispatchedSlotEvents.has(event.nativeEvent)) { + if (!wasSlotHandlerDispatched(event.nativeEvent, event.currentTarget)) { onKeyDownCapture?.(event); } }; slotProps.onKeyDown = event => { - if (!dispatchedSlotEvents.has(event.nativeEvent)) { + 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); } }; @@ -354,7 +367,6 @@ export const useCalendarDayGridCell_unstable = ( const isSlotRootTarget = ev.target === buttonRef.current || ev.target === dayLabelRef.current || ev.target === markerRef.current; if (isSlotRootTarget) { - dispatchedSlotEvents.add(ev.nativeEvent); const slotHandlers = [ (capture: boolean) => forwardButton(ev, capture, buttonRef.current), (capture: boolean) => forwardDayLabel(ev, capture, dayLabelRef.current), From 84808257403c8a45d111befb74dc4bec0505aff7 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 15 Oct 2026 17:14:46 +0200 Subject: [PATCH 16/16] fix(react-calendar-preview): defer custom year arrows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e706f1a3-12e4-4e0a-bad1-c38b23bd9449 --- .../library/docs/Spec.md | 5 +- .../src/components/Calendar/Calendar.cy.tsx | 52 +++++++++++++++++++ .../useCalendarYearGridCell.ts | 2 +- 3 files changed, 56 insertions(+), 3 deletions(-) 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 d5769b7d37ec28..7f8f617aa02a87 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -256,8 +256,9 @@ Handled arrows, including boundary keys, are prevented so Tabster or a native fo 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 button and day-label slot handlers, including handlers on custom descendants, run in natural capture and -bubbling order with the correct `currentTarget`. Only handlers on the target's ancestor path run. +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. 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 6946defd9ba87e..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 @@ -484,6 +484,58 @@ describe('Calendar', () => { } }); }); + + 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', () => { 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 d8581eff036187..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 @@ -60,7 +60,7 @@ export const useCalendarYearGridCell_unstable = ( } }, nativeProps.onKeyDownCapture, - isCalendarGridArrow, + event => isCalendarGridArrow(event) && event.target === event.currentTarget, ); const root = slot.always>>(