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,7 @@
{
"type": "patch",
"comment": "refactor(react-calendar): migrate to motion components",
"packageName": "@fluentui/react-calendar-compat",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@fluentui/keyboard-keys": "^9.0.8",
"@fluentui/react-icons": "^2.0.245",
"@fluentui/react-jsx-runtime": "^9.4.3",
"@fluentui/react-motion": "^9.15.0",
"@fluentui/react-motion-components-preview": "^0.15.4",
"@fluentui/react-shared-contexts": "^9.26.2",
"@fluentui/react-tabster": "^9.26.15",
"@fluentui/react-theme": "^9.2.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useCalendarDayStyles_unstable } from './useCalendarDayStyles.styles';
import type { ICalendarDayGrid } from '../CalendarDayGrid/CalendarDayGrid.types';
import type { CalendarDayProps, CalendarDayStyles } from './CalendarDay.types';
import type { JSXElement } from '@fluentui/react-utilities';
import { AnimationDirection } from '../../Calendar';

/**
* @internal
Expand Down Expand Up @@ -40,7 +41,7 @@ export const CalendarDay: React.FunctionComponent<CalendarDayProps> = props => {
onNavigateDate,
showWeekNumbers,
dateRangeType,
animationDirection,
animationDirection = AnimationDirection.Vertical,
} = props;

const classNames = useCalendarDayStyles_unstable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { tokens } from '@fluentui/react-theme';
import { makeStyles, mergeClasses, shorthands } from '@griffel/react';
import { DURATION_2, EASING_FUNCTION_2, FADE_IN } from '../../utils/animations';
import type { SlotClassNames } from '@fluentui/react-utilities';
import type { CalendarDayStyles, CalendarDayStyleProps } from './CalendarDay.types';

Expand Down Expand Up @@ -64,12 +63,6 @@ const useMonthAndYearStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
animation: {
animationDuration: DURATION_2,
animationFillMode: 'both',
animationName: FADE_IN,
animationTimingFunction: EASING_FUNCTION_2,
},
headerIsClickable: {
'&:hover': {
backgroundColor: tokens.colorBrandBackgroundInvertedHover,
Expand Down Expand Up @@ -166,7 +159,6 @@ export const useCalendarDayStyles_unstable = (props: CalendarDayStyleProps): Cal
monthAndYear: mergeClasses(
calendarDayClassNames.monthAndYear,
monthAndYearStyles.base,
monthAndYearStyles.animation,
headerIsClickable && monthAndYearStyles.headerIsClickable,
),
monthComponents: mergeClasses(calendarDayClassNames.monthComponents, monthComponentsStyles.base),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,61 @@ describe('CalendarDayGrid', () => {
expect(navigatedTo.getDate()).toBe(7);
});
});

describe('week-row DOM element identity across month navigation', () => {
it('reuses the same <tr> DOM elements when navigating between months', () => {
// Regression test: a `key` on the <tr> inside CalendarGridRow that encoded the week's
// first-day date string caused React to unmount+remount the <tr> on every navigation.
// This detached the element from the Web Animations API handle held by Slide.In,
// making slide-in replay silently target a stale disconnected node.
//
// Without that key, React reuses the same <tr> DOM element across navigations —
// animations remain connected and can be replayed.
const { container, rerender } = render(<CalendarDayGrid {...defaultProps} />);
const tbody = container.querySelector('tbody')!;

// Only the persistent week rows must keep their DOM identity — they are what `Slide.In`
// replays against on navigation. The first/last transition (filler) rows are intentionally
// remounted when they start or stop animating (their `DirectionalSlideOut` wrapper mounts
// only for the matching navigation direction), so they are excluded here.
const getWeekRows = () => Array.from(tbody.querySelectorAll('tr.fui-CalendarDayGrid__weekRow'));
const rowsBefore = getWeekRows();
expect(rowsBefore.length).toBeGreaterThan(0);

// Navigate to October 2020.
rerender(<CalendarDayGrid {...defaultProps} navigatedDate={new Date(2020, 9, 1)} />);

const rowsAfter = getWeekRows();

// Every week row present in both months must be the same DOM node — not a new element.
const sharedCount = Math.min(rowsBefore.length, rowsAfter.length);
for (let i = 0; i < sharedCount; i++) {
expect(rowsAfter[i]).toBe(rowsBefore[i]);
}
});
});

// Motion-component wrappers (DirectionalSlide, Fade.In) must remain transparent —
// table semantics require <tr> to be a direct child of <tbody> and <th>/<td>
// to be direct children of <tr>. Any wrapper element would break a11y and CSS.
describe('motion wrappers preserve table structure', () => {
it('renders week rows as direct children of <tbody>', () => {
const { container } = render(<CalendarDayGrid {...defaultProps} />);
const tbody = container.querySelector('tbody');
expect(tbody).not.toBeNull();
Array.from(tbody!.children).forEach(child => {
expect(child.tagName).toBe('TR');
});
});

it('renders weekday label cells as direct children of the header <tr>', () => {
const { container } = render(<CalendarDayGrid {...defaultProps} />);
// The header row contains the weekday label <th> cells (Sun, Mon, …)
const headerCells = container.querySelectorAll('th[scope="col"]');
expect(headerCells.length).toBeGreaterThan(0);
headerCells.forEach(cell => {
expect(cell.parentElement?.tagName).toBe('TR');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { useWeekCornerStyles } from './useWeekCornerStyles.styles';
import { mergeClasses } from '@griffel/react';
import type { Day, DayOfWeek } from '../../utils';
import type { CalendarDayGridProps } from './CalendarDayGrid.types';
import { DirectionalSlideIn, DirectionalSlideOut } from '../../utils/calendarMotions';
import { AnimationDirection } from '../../Calendar';

export interface DayInfo extends Day {
onSelected: () => void;
Expand Down Expand Up @@ -79,6 +81,7 @@ export const CalendarDayGrid: React.FunctionComponent<CalendarDayGridProps> = pr

const weeks = useWeeks(props, onSelectDate, getSetRefCallback);
const animateBackwards = useAnimateBackwards(weeks);

const [getWeekCornerStyles, calculateRoundedStyles] = useWeekCornerStyles(props);

React.useImperativeHandle(
Expand Down Expand Up @@ -134,7 +137,7 @@ export const CalendarDayGrid: React.FunctionComponent<CalendarDayGridProps> = pr
showWeekNumbers,
labelledBy,
lightenDaysOutsideNavigatedMonth,
animationDirection,
animationDirection = AnimationDirection.Vertical,
} = props;

const classNames = useCalendarDayGridStyles_unstable({
Expand All @@ -160,6 +163,12 @@ export const CalendarDayGrid: React.FunctionComponent<CalendarDayGridProps> = pr
} as const;

const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid-linear' });
const firstWeek = weeks[0];
const finalWeek = weeks![weeks!.length - 1];
// Single navigation epoch for all rows in the grid. Derived from the first visible day's key
// (`Date.toString()`), which changes when the user navigates to a different month but stays
// stable across intra-month interactions (e.g. day selection).
const navigationEpoch = firstWeek[0].key;

return (
<table
Expand All @@ -173,34 +182,54 @@ export const CalendarDayGrid: React.FunctionComponent<CalendarDayGridProps> = pr
>
<tbody>
<CalendarMonthHeaderRow {...props} classNames={classNames} weeks={weeks} />
<CalendarGridRow
{...props}
{...partialWeekProps}
week={weeks[0]}
weekIndex={-1}
rowClassName={classNames.firstTransitionWeek}
aria-role="presentation"
ariaHidden={true}
/>
{weeks!.slice(1, weeks!.length - 1).map((week: DayInfo[], weekIndex: number) => (
<DirectionalSlideOut
edge="first"
replayKey={navigationEpoch}
animationDirection={animationDirection}
animateBackwards={animateBackwards}
>
<CalendarGridRow
{...props}
{...partialWeekProps}
key={weekIndex}
week={week}
weekIndex={weekIndex}
rowClassName={classNames.weekRow}
week={firstWeek}
weekIndex={-1}
rowClassName={classNames.firstTransitionWeek}
aria-role="presentation"
ariaHidden={true}
/>
</DirectionalSlideOut>
{weeks!.slice(1, weeks!.length - 1).map((week: DayInfo[], weekIndex: number) => (
<DirectionalSlideIn
key={weekIndex}
replayKey={navigationEpoch}
animationDirection={animationDirection}
animateBackwards={animateBackwards}
>
<CalendarGridRow
{...props}
{...partialWeekProps}
week={week}
weekIndex={weekIndex}
rowClassName={classNames.weekRow}
/>
</DirectionalSlideIn>
))}
<CalendarGridRow
{...props}
{...partialWeekProps}
week={weeks![weeks!.length - 1]}
weekIndex={-2}
rowClassName={classNames.lastTransitionWeek}
aria-role="presentation"
ariaHidden={true}
/>
<DirectionalSlideOut
edge="last"
replayKey={navigationEpoch}
animationDirection={animationDirection}
animateBackwards={animateBackwards}
>
<CalendarGridRow
{...props}
{...partialWeekProps}
week={finalWeek}
weekIndex={-2}
rowClassName={classNames.lastTransitionWeek}
aria-role="presentation"
ariaHidden={true}
/>
</DirectionalSlideOut>
</tbody>
</table>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import * as React from 'react';
import { getWeekNumbersInMonth } from '../../utils';
import { CalendarGridDayCell } from './CalendarGridDayCell';
Expand Down Expand Up @@ -28,7 +30,7 @@ export interface CalendarGridRowProps extends CalendarDayGridProps {
/**
* @internal
*/
export const CalendarGridRow: React.FunctionComponent<CalendarGridRowProps> = props => {
export const CalendarGridRow = React.forwardRef<HTMLTableRowElement, CalendarGridRowProps>((props, ref) => {
const {
ariaHidden,
classNames,
Expand All @@ -52,15 +54,9 @@ export const CalendarGridRow: React.FunctionComponent<CalendarGridRowProps> = pr
: '';

return (
<tr role={ariaRole} aria-hidden={ariaHidden} className={rowClassName} key={weekIndex + '_' + week[0].key}>
<tr ref={ref} role={ariaRole} aria-hidden={ariaHidden} className={rowClassName}>
{showWeekNumbers && weekNumbers && (
<th
className={classNames.weekNumberCell}
key={weekIndex}
title={titleString}
aria-label={titleString}
scope="row"
>
<th className={classNames.weekNumberCell} title={titleString} aria-label={titleString} scope="row">
<span>{weekNumbers[weekIndex]}</span>
</th>
)}
Expand All @@ -69,4 +65,6 @@ export const CalendarGridRow: React.FunctionComponent<CalendarGridRowProps> = pr
))}
</tr>
);
};
});

CalendarGridRow.displayName = 'CalendarGridRow';
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from 'react';
import { mergeClasses } from '@griffel/react';
import { motionTokens } from '@fluentui/react-motion';
import { Fade } from '@fluentui/react-motion-components-preview';
import { DAYS_IN_WEEK } from '../../utils';
import type { CalendarDayGridProps, CalendarDayGridStyles } from './CalendarDayGrid.types';
import type { DayInfo } from './CalendarDayGrid';
Expand Down Expand Up @@ -41,16 +43,20 @@ export const CalendarMonthHeaderRow: React.FunctionComponent<CalendarDayMonthHea
const i = (index + firstDayOfWeek) % DAYS_IN_WEEK;
const label = strings.days[i];
return (
<th
className={mergeClasses(classNames.dayCell, classNames.weekDayLabelCell)}
scope="col"
key={dayLabels[i] + ' ' + index}
title={label}
aria-label={label}
tabIndex={allFocusable ? 0 : undefined}
>
{dayLabels[i]}
</th>
// Plain list key, not a `replayKey`: day labels are stable across navigation so the fade
// plays once on mount. The only remount is in single-week view, when a label is swapped
// for a short month name.
<Fade.In key={dayLabels[i] + ' ' + index} duration={motionTokens.durationGentle}>
<th
className={mergeClasses(classNames.dayCell, classNames.weekDayLabelCell)}
scope="col"
title={label}
aria-label={label}
tabIndex={allFocusable ? 0 : undefined}
>
{dayLabels[i]}
</th>
</Fade.In>
);
})}
</tr>
Expand Down
Loading
Loading