Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
346682c
chore(react-calendar-preview): prepare initial release
dmytrokirpa Sep 5, 2026
247264a
fix(ci): conditionally cache Yarn for deduplication
dmytrokirpa Oct 13, 2026
792c666
fix(ci): build the LLM extractor before the v9 docsite
dmytrokirpa Oct 13, 2026
f8ad53c
fix(react-calendar-preview): track picker focus without styling classes
dmytrokirpa Oct 13, 2026
c21fd6d
fix(react-calendar-preview): restore base grid keyboard ownership
dmytrokirpa Oct 13, 2026
d18187c
fix(react-calendar-preview): preserve nested focusgroup arrow ownership
dmytrokirpa Oct 14, 2026
83f6d20
chore(react-calendar-preview): limit release PR to publication metadata
dmytrokirpa Oct 14, 2026
1a6e061
fix(react-calendar-preview): preserve focus and grid keyboard ownership
dmytrokirpa Oct 14, 2026
b6eb029
chore(react-calendar-preview): remove duplicate release metadata
dmytrokirpa Oct 14, 2026
641c561
Merge branch 'master' into dmytrokirpa-calendar-keyboard-fixes
dmytrokirpa Oct 15, 2026
a5cc766
fix(react-calendar-preview): preserve modified day arrows
dmytrokirpa Oct 15, 2026
efdf541
fix(react-calendar-preview): honor keyboard cancellations
dmytrokirpa Oct 15, 2026
d197816
test(react-calendar-preview): verify legacy event deduplication
dmytrokirpa Oct 15, 2026
19f1c92
fix(react-calendar-preview): preserve keyboard event paths
dmytrokirpa Oct 15, 2026
462400a
fix(react-calendar-preview): honor nested keyboard events
dmytrokirpa Oct 15, 2026
8bf7715
fix(react-calendar-preview): scope keyboard dedup to slots
dmytrokirpa Oct 15, 2026
8480825
fix(react-calendar-preview): defer custom year arrows
dmytrokirpa Oct 15, 2026
d4c2de9
Merge branch 'master' into dmytrokirpa-calendar-keyboard-fixes
dmytrokirpa Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "patch",
"comment": "fix: restore class-independent Calendar focus, two-dimensional grid arrows that skip unfocusable cells, and consumer keyboard event ordering and cancellation",
"packageName": "@fluentui/react-calendar-preview",
"email": "dmytrokirpa@microsoft.com"
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,35 @@ Calendar owns selected-date, displayed-date, and picker-view state. The day and

The styled components use Tabster arrow-navigation groups. Base state hooks are exported for a headless layer and intentionally leave picker resolution or roving-focus behavior to the caller.

Month and year cells own two-dimensional arrow navigation in both layers. Horizontal arrows stay in the current
four-cell row, respect RTL, and do not wrap; vertical arrows stay in the same column. Unavailable cells are skipped
unless `allFocusable` is enabled, which permits focus but not selection. Consumer cell handlers can cancel the
built-in action with `preventDefault()`. Modified arrows and editable descendants are left untouched.
Handled arrows, including boundary keys, are prevented so Tabster or a native focusgroup does not move focus again.

Owned day/month/year arrows dispatch the composed cell keyboard handler during capture, before a native grid
owner's bubbling listener. Consumer `onKeyDownCapture` runs first; consumer `onKeyDown` runs exactly once with the
cell as `currentTarget`, and `preventDefault()` cancels movement without an intermediate focus hop. Day 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
navigation without suppressing later consumer callbacks; `stopPropagation()` suppresses later callbacks,
but a cell bubbling handler can stop propagation without cancelling Calendar navigation.
Arrows from independently nested grids or descendant `[focusgroup]` owners, including `focusgroup="none"`,
remain in bubbling without Calendar navigation. A focusgroup on the Calendar grid itself or outside that grid
does not change Calendar's arrow ownership.
This ordering relies on React 17+ capture delegation. There is no special React 16 bridge for native grid owners.
Other keys, including Enter and PageUp/PageDown, retain their normal bubbling dispatch.

Month/year navigation continues past cells that cannot receive focus, including hidden cells, while allowing
programmatic navigation to cells with `tabIndex="-1"`.

Editable keyboard detection honors the nearest `contenteditable` boundary: descendants of
`contenteditable="false"` remain navigable even inside an editable ancestor.

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:
Expand All @@ -256,7 +285,7 @@ The default DOM is equivalent to:
<div class="fui-Calendar">
<div aria-live="polite" aria-atomic="true">Selected: June 18, 2025</div>

<div class="fui-CalendarDay">
<div class="fui-CalendarDay" data-fui-calendar-picker="day">
<div>
<div><span aria-live="polite">June 2025</span></div>
<div>
Expand All @@ -279,7 +308,7 @@ The default DOM is equivalent to:
<div class="fui-Calendar__divider"></div>

<div>
<div class="fui-CalendarMonth">
<div class="fui-CalendarMonth" data-fui-calendar-picker="month">
<div>... year heading and previous/next year buttons ...</div>
<div role="grid" aria-label="2025">... month gridcells ...</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<Calendar defaultValue={today} today={today} layout="auto" />);

expect(markup).toContain('data-fui-calendar-picker="day"');
expect(markup).toContain('data-fui-calendar-picker="month"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FluentProvider theme={webLightTheme}>{element}</FluentProvider>);
const mountFluent = (element: JSXElement, dir: 'ltr' | 'rtl' = 'ltr') => {
mount(
<FluentProvider theme={webLightTheme} dir={dir}>
{element}
</FluentProvider>,
);
};

/**
Expand All @@ -33,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(
<Calendar
today={today}
defaultValue={today}
dayPicker={{ getDayCellProps: () => ({ 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(
<Calendar
today={today}
defaultValue={today}
dayPicker={{
getDayCellProps: () => ({
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(
<Calendar
today={today}
defaultValue={today}
dayPicker={{ getDayCellProps: () => ({ 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(<Calendar today={today} defaultValue={today} onDisplayedDateChange={onDisplayedDateChange} />);
Expand Down Expand Up @@ -243,6 +299,124 @@ describe('Calendar', () => {
});

describe('month and year pickers', () => {
(['month', 'year'] as const).forEach(picker => {
const cells = '[data-fui-calendar-picker="month"] button[role="gridcell"]';
const openYearPicker = () => {
if (picker === 'year') {
cy.get(`.${calendarMonthClassNames.heading}`).click();
}
};

it(`skips hidden ${picker} cells and still focuses tabIndex=-1 candidates`, () => {
mountFluent(<Calendar today={today} defaultValue={today} layout="sideBySide" />);
openYearPicker();

cy.get(cells).eq(1).invoke('prop', 'hidden', true).should('not.be.visible');
cy.get(cells).eq(2).invoke('attr', 'tabindex', '-1');
cy.get(cells).eq(0).focus().realPress('ArrowRight');
cy.get(cells).eq(2).should('be.focused');
});

it(`moves the ${picker} grid exactly once per arrow without wrapping rows or columns`, () => {
const onSelectDate = cy.stub().as('onSelectDate');
const onDisplayedDateChange = cy.stub().as('onDisplayedDateChange');
mountFluent(
<Calendar
today={today}
defaultValue={today}
layout="sideBySide"
onSelectDate={onSelectDate}
onDisplayedDateChange={onDisplayedDateChange}
/>,
);
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(<Calendar today={today} defaultValue={today} layout="sideBySide" />, '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(
<>
<Calendar today={today} defaultValue={today} layout="sideBySide" goToTodayButton={null} />
<button type="button">After Calendar</button>
</>,
);
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(
<Calendar
today={today}
defaultValue={today}
layout="sideBySide"
monthPicker={
picker === 'month' ? { grid: { onKeyDownCapture } } : { yearPicker: { grid: { onKeyDownCapture } } }
}
/>,
);
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(
<Calendar
today={today}
defaultValue={today}
layout="sideBySide"
allFocusable={allFocusable}
minDate={picker === 'month' ? new Date(2020, 1, 1) : new Date(2021, 0, 1)}
monthPicker={{ yearPicker: { navigatedYear: 2020 } }}
onSelectDate={onSelectDate}
/>,
);
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(<Calendar today={today} value={today} highlightSelectedMonth />);

Expand Down
Loading
Loading