Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: Align Calendar sizing, popup focus, and keyboard selection behavior.",
"packageName": "@fluentui/react-calendar-preview",
"email": "dmytrokirpa@microsoft.com"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,18 @@
**React Calendar components for [Fluent UI React](https://react.fluentui.dev/)**

These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release.

The `CalendarDay`, `CalendarMonth`, and `CalendarYear` ref handles accept native `FocusOptions`.
Use `ref.current?.focus({ preventScroll: true })` when moving focus into a positioned popup so
focus entry does not scroll the surrounding page. Calling `focus()` without options retains the
default browser scrolling behavior.

Calendar uses the compat picker footprint for single-picker, side-by-side, and week-number
layouts. The divider does not increase the overall width, and the "Go to today" action retains
the compat height and spacing. These defaults are applied by Calendar itself, including when
hosted in DatePicker; consumer slot styles still take precedence.

Keyboard date selection invokes date-selection callbacks before preventing the browser's default
Enter or Space behavior. Consumers can therefore distinguish canceled date selection from a handled
native key action, as they can with pointer activation. This ordering guarantee does not apply to
navigation or action callbacks.
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export type CalendarDayGridRowState = ComponentState<CalendarDayGridRowSlots> &

// @public
export type CalendarDayHandle = {
focus(): void;
focus(options?: FocusOptions): void;
};

// @public
Expand Down Expand Up @@ -417,7 +417,7 @@ export type CalendarMonthGridRowState = ComponentState<CalendarMonthGridRowSlots

// @public
export type CalendarMonthHandle = {
focus(): void;
focus(options?: FocusOptions): void;
};

// @public
Expand Down Expand Up @@ -642,7 +642,7 @@ export type CalendarYearGridRowState = ComponentState<CalendarYearGridRowSlots>

// @public
export type CalendarYearHandle = {
focus(): void;
focus(options?: FocusOptions): void;
};

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { calendarMonthClassNames } from '../CalendarMonth/useCalendarMonthStyles
import { calendarMonthGridCellClassNames } from '../CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles';
import { calendarYearGridCellClassNames } from '../CalendarYearGridCell/useCalendarYearGridCellStyles.styles';
import type { JSXElement } from '@fluentui/react-utilities';
import type { CalendarProps } from './Calendar.types';

const mountFluent = (element: JSXElement, dir: 'ltr' | 'rtl' = 'ltr') => {
mount(
Expand All @@ -36,6 +37,96 @@ const nextMonth = `.${calendarDayClassNames.nextMonthButton}`;
const goToToday = `.${calendarClassNames.goToTodayButton}`;

describe('Calendar', () => {
describe('layout geometry', () => {
const layouts: { name: string; props: CalendarProps; width: number; monthOnly?: boolean }[] = [
{ name: 'side by side', props: { layout: 'sideBySide' }, width: 440 },
{ name: 'side by side with week numbers', props: { layout: 'sideBySide', showWeekNumbers: true }, width: 470 },
{ name: 'day only', props: { monthPicker: null }, width: 220 },
{ name: 'day only with week numbers', props: { monthPicker: null, showWeekNumbers: true }, width: 250 },
{ name: 'overlay', props: { layout: 'overlay' }, width: 220 },
{ name: 'month only', props: { dayPicker: null, showWeekNumbers: true }, width: 220, monthOnly: true },
];

for (const dir of ['ltr', 'rtl'] as const) {
for (const sixWeeks of [false, true]) {
for (const { name, props, width, monthOnly } of layouts) {
it(`preserves ${name} dimensions with ${sixWeeks ? 'six' : 'five'} weeks in ${dir}`, () => {
const date = sixWeeks ? new Date(2020, 7, 18) : today;
const dayHeight = sixWeeks ? 248 : 220;
const sideBySide = props.layout === 'sideBySide';
const height = monthOnly ? 241 : sideBySide ? Math.max(dayHeight, 241) : dayHeight + 33;
mountFluent(<Calendar today={date} defaultValue={date} {...props} />, dir);

cy.get(`.${calendarClassNames.root}`).should(elements => {
const root = elements[0];
const bounds = root.getBoundingClientRect();
expect(bounds.width).to.equal(width);
expect(bounds.height).to.equal(height);
const action = root.querySelector<HTMLButtonElement>(goToToday)!;
const actionBounds = action.getBoundingClientRect();
const actionStyles = getComputedStyle(action);
expect(actionBounds.height).to.equal(30);
expect(actionStyles.marginTop).to.equal('3px');
expect(actionStyles.lineHeight).to.equal('30px');

if (sideBySide) {
const dayPicker = root.querySelector<HTMLElement>(`.${calendarDayClassNames.root}`)!;
const monthPicker = root.querySelector<HTMLElement>(`.${calendarMonthClassNames.root}`)!;
const dayBounds = dayPicker.getBoundingClientRect();
const monthBounds = monthPicker.getBoundingClientRect();
expect(dayBounds.width).to.equal(props.showWeekNumbers ? 250 : 220);
expect(monthBounds.width).to.equal(220);
expect(
dir === 'ltr' ? monthBounds.left - dayBounds.right : dayBounds.left - monthBounds.right,
).to.equal(1);
}
});
});
}
}
}

it('does not reserve action space when the action is hidden', () => {
mountFluent(<Calendar today={today} defaultValue={today} layout="sideBySide" goToTodayButton={null} />);
cy.get(`.${calendarClassNames.root}`).should(elements => {
const bounds = elements[0].getBoundingClientRect();
expect(bounds.width).to.equal(440);
expect(bounds.height).to.equal(220);
});
cy.get(goToToday).should('not.exist');
});

it('retains the single-picker footprint when overlay navigation switches views', () => {
mountFluent(<Calendar today={today} defaultValue={today} layout="overlay" />);
cy.get(heading).click();
cy.get(`.${calendarMonthClassNames.root}`).should('be.visible');
cy.get(`.${calendarClassNames.root}`).should(elements => {
const bounds = elements[0].getBoundingClientRect();
expect(bounds.width).to.equal(220);
expect(bounds.height).to.equal(241);
});
cy.get(`.${calendarMonthClassNames.heading}`).click();
cy.get(`.${calendarClassNames.root}`).should(elements => {
const bounds = elements[0].getBoundingClientRect();
expect(bounds.width).to.equal(220);
expect(bounds.height).to.equal(241);
});
});

it('preserves consumer root and action sizing overrides', () => {
mountFluent(
<Calendar
today={today}
defaultValue={today}
style={{ width: 500 }}
goToTodayButton={{ style: { height: 40, lineHeight: '40px', marginTop: 0 } }}
/>,
);
cy.get(`.${calendarClassNames.root}`).should('have.css', 'width', '500px');
cy.get(goToToday).should('have.css', 'height', '40px').and('have.css', 'margin-top', '0px');
});
});

describe('day grid keyboard navigation', () => {
it('keeps enclosing day-label callbacks when a nested Calendar handles an arrow', () => {
const onOuterKeyDown = cy.stub().as('onOuterKeyDown');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ import type { CalendarMonthHandle } from '../CalendarMonth/CalendarMonth.types';
expect.extend(toHaveNoViolations);

describe('Calendar', () => {
it.each(['Enter', ' '])('notifies day selection before preventing native %s activation', key => {
const onSelectDate = jest.fn(event => expect(event.defaultPrevented).toBe(false));
const { getByRole } = render(<Calendar today={new Date(2030, 0, 15)} onSelectDate={onSelectDate} />);
const cell = getByRole('button', { name: 'January 16, 2030' }).closest('td')!;
expect(fireEvent.keyDown(cell, { key })).toBe(false);
expect(onSelectDate).toHaveBeenCalledTimes(1);
});

it('notifies month-only selection before preventing native Enter activation', () => {
const onSelectDate = jest.fn(event => expect(event.defaultPrevented).toBe(false));
const { getByRole } = render(
<Calendar today={new Date(2030, 0, 15)} dayPicker={null} onSelectDate={onSelectDate} />,
);
expect(fireEvent.keyDown(getByRole('gridcell', { name: 'February' }), { key: 'Enter' })).toBe(false);
expect(onSelectDate).toHaveBeenCalledTimes(1);
});

isConformant({
Component: Calendar,
displayName: 'Calendar',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,8 @@ export const useCalendarBase_unstable = (
onClick: onGotoToday,
onKeyDown: (ev: React.KeyboardEvent<HTMLButtonElement>) => {
if (ev.key === Enter) {
ev.preventDefault();
onGotoToday(ev);
ev.preventDefault();
}
},
type: 'button',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { tokens } from '@fluentui/react-theme';
import { makeStyles, mergeClasses } from '@griffel/react';
import { calendarGridWidth, calendarGridWidthWithWeekNumbers } from '../../utils/calendarDimensions';
import type { SlotClassNames } from '@fluentui/react-utilities';
import type { CalendarSlots, CalendarState } from './Calendar.types';

Expand All @@ -21,7 +22,8 @@ export const calendarClassNames: SlotClassNames<CalendarSlots> = {
const useRootStyles = makeStyles({
base: {
display: 'flex',
width: 'fit-content',
// The divider belongs inside the picker footprint, not in addition to it.
width: `calc(${calendarGridWidth} + 2 * ${tokens.spacingHorizontalM})`,
},
normalize: {
boxShadow: 'none',
Expand All @@ -32,6 +34,15 @@ const useRootStyles = makeStyles({
monthPickerNotVisible: {
flexDirection: 'column',
},
dayAndMonthPickersVisible: {
width: `calc(2 * ${calendarGridWidth} + 4 * ${tokens.spacingHorizontalM})`,
},
dayPickerWithWeekNumbers: {
width: `calc(${calendarGridWidthWithWeekNumbers} + 2 * ${tokens.spacingHorizontalM})`,
},
dayAndMonthPickersWithWeekNumbers: {
width: `calc(${calendarGridWidthWithWeekNumbers} + ${calendarGridWidth} + 4 * ${tokens.spacingHorizontalM})`,
},
});

const useDividerStyles = makeStyles({
Expand Down Expand Up @@ -59,12 +70,14 @@ const useGoTodayButtonStyles = makeStyles({
fontFamily: 'inherit',
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightRegular,
// Preserve compat's 30px action and 3px top gap using the surrounding spacing/stroke tokens.
height: `calc(${tokens.spacingVerticalXXXL} - 2 * ${tokens.strokeWidthThin})`,
minWidth: 'auto',
lineHeight: tokens.lineHeightBase200,
lineHeight: `calc(${tokens.spacingVerticalXXXL} - 2 * ${tokens.strokeWidthThin})`,
marginInlineEnd: tokens.spacingHorizontalL,
marginBlockStart: tokens.spacingVerticalXXS,
marginBlockStart: `calc(${tokens.spacingVerticalXXS} + ${tokens.strokeWidthThin})`,
overflow: 'visible',
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXS}`,
padding: `0 ${tokens.spacingHorizontalXS}`,
'&:hover': {
backgroundColor: tokens.colorTransparentBackground,
color: tokens.colorBrandForeground1,
Expand Down Expand Up @@ -107,14 +120,17 @@ export const useCalendarStyles_unstable = (state: CalendarState): CalendarState
const goTodayButtonStyles = useGoTodayButtonStyles();
const liveRegionStyles = useLiveRegionStyles();

const { isMonthPickerVisible } = state;
const { isDayPickerVisible, isMonthPickerVisible, showWeekNumbers } = state;

/* eslint-disable react-hooks/immutability */
state.root.className = mergeClasses(
calendarClassNames.root,
rootStyles.base,
rootStyles.normalize,
!isMonthPickerVisible && rootStyles.monthPickerNotVisible,
isDayPickerVisible && isMonthPickerVisible && rootStyles.dayAndMonthPickersVisible,
isDayPickerVisible && showWeekNumbers && rootStyles.dayPickerWithWeekNumbers,
isDayPickerVisible && isMonthPickerVisible && showWeekNumbers && rootStyles.dayAndMonthPickersWithWeekNumbers,
state.root.className,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ describe('CalendarDay', () => {
expect(document.activeElement).toBe(navigatedCell);
});

it('forwards native focus options to the navigated day', () => {
const ref = React.createRef<CalendarDayHandle>();
const { container } = render(<CalendarDay {...defaultProps} ref={ref} />);
const cell = container.querySelector('button[aria-label="September 18, 2020"]')!.closest('td')!;
const focus = jest.spyOn(cell, 'focus');
ref.current!.focus({ preventScroll: true });
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(document.activeElement).toBe(cell);
focus.mockRestore();
});

it('preserves selection and focus with customized day content, props, and refs', () => {
const ref = React.createRef<CalendarDayHandle>();
const cellRef = React.createRef<HTMLTableCellElement>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type CalendarDayHandle = {
/**
* Moves focus to the day the grid is currently navigated to.
*/
focus(): void;
focus(options?: FocusOptions): void;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ const onButtonKeyDown =
): ((ev: React.KeyboardEvent<HTMLButtonElement>) => void) =>
ev => {
if (ev.key === Enter) {
ev.preventDefault();
callback?.(ev);
ev.preventDefault();
}
};

Expand Down Expand Up @@ -109,8 +109,8 @@ export const useCalendarDayBase_unstable = (
React.useImperativeHandle(
ref,
() => ({
focus() {
navigatedDayRef.current?.focus?.();
focus(options?: FocusOptions) {
navigatedDayRef.current?.focus?.(options);
},
}),
[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { tokens } from '@fluentui/react-theme';
import { makeStyles, mergeClasses } from '@griffel/react';
import { calendarGridWidth, calendarGridWidthWithWeekNumbers } from '../../utils/calendarDimensions';
import { useCalendarContext_unstable } from '../../contexts/calendarContext';
import type { SlotClassNames } from '@fluentui/react-utilities';
import type { CalendarDaySlots, CalendarDayState } from './CalendarDay.types';
Expand All @@ -27,7 +28,7 @@ const useRootStyles = makeStyles({
base: {
boxSizing: 'content-box',
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
width: '196px',
width: calendarGridWidth,
},
normalize: {
boxShadow: 'none',
Expand All @@ -36,7 +37,7 @@ const useRootStyles = makeStyles({
padding: '0',
},
showWeekNumbers: {
width: '226px',
width: calendarGridWidthWithWeekNumbers,
},
});

Expand All @@ -60,10 +61,10 @@ const useGridStyles = makeStyles({
position: 'relative',
tableLayout: 'fixed',
textAlign: 'center',
width: '196px',
width: calendarGridWidth,
},
showWeekNumbers: {
width: '226px',
width: calendarGridWidthWithWeekNumbers,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,12 @@ export const useCalendarDayGridCell_unstable = (
}

if ((ev.key === Enter || ev.key === Space) && day.isInBounds) {
ev.preventDefault();
/*
* `day.onSelected` is the grid's own handler, so keyboard activation resolves the same date range and
* navigation as a click does.
*/
day.onSelected(ev);
ev.preventDefault();
} else {
navigateMonthEdge(ev, originalDate);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ describe('CalendarMonth', () => {
expect(() => render(<CalendarMonth {...defaultProps} />)).not.toThrow();
});

it.each([false, true])('forwards focus options with year picker open=%s', yearPickerOpen => {
const ref = React.createRef<CalendarMonthHandle>();
const { getByRole } = render(<CalendarMonth {...defaultProps} ref={ref} />);
if (yearPickerOpen) {
fireEvent.click(getByRole('button', { name: '2025, change year' }));
}
const cell = getByRole('gridcell', { name: yearPickerOpen ? '2025' : 'January' });
const focus = jest.spyOn(cell, 'focus');
ref.current!.focus({ preventScroll: true });
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(cell).toHaveFocus();
focus.mockRestore();
});

it('preserves an explicitly empty selection when opening the year picker', () => {
const { getByRole, getAllByRole } = render(<CalendarMonth {...defaultProps} selectedDate={null} />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type CalendarMonthHandle = {
* Moves focus to the navigated month (or the first available month if it is disabled),
* or to the year picker while it is open.
*/
focus(): void;
focus(options?: FocusOptions): void;
};

/**
Expand Down
Loading
Loading