diff --git a/change/@fluentui-react-calendar-compat-51c1ee36-d9f0-4d16-9a35-8e1fda9424e3.json b/change/@fluentui-react-calendar-compat-51c1ee36-d9f0-4d16-9a35-8e1fda9424e3.json new file mode 100644 index 00000000000000..dec3716fecb3f9 --- /dev/null +++ b/change/@fluentui-react-calendar-compat-51c1ee36-d9f0-4d16-9a35-8e1fda9424e3.json @@ -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" +} diff --git a/packages/react-components/react-calendar-compat/library/package.json b/packages/react-components/react-calendar-compat/library/package.json index b03511ab9ff3b1..dd0c6e11aadc0e 100644 --- a/packages/react-components/react-calendar-compat/library/package.json +++ b/packages/react-components/react-calendar-compat/library/package.json @@ -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", diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/CalendarDay.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/CalendarDay.tsx index 2e52f1b2f45207..cce90fc28e6f72 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/CalendarDay.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/CalendarDay.tsx @@ -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 @@ -40,7 +41,7 @@ export const CalendarDay: React.FunctionComponent = props => { onNavigateDate, showWeekNumbers, dateRangeType, - animationDirection, + animationDirection = AnimationDirection.Vertical, } = props; const classNames = useCalendarDayStyles_unstable({ diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/useCalendarDayStyles.styles.ts b/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/useCalendarDayStyles.styles.ts index 2a6cbc25530a29..a515cf11b3f7b2 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/useCalendarDayStyles.styles.ts +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDay/useCalendarDayStyles.styles.ts @@ -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'; @@ -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, @@ -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), diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.test.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.test.tsx index 3a7c369dc9e775..e339d96ec00518 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.test.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.test.tsx @@ -152,4 +152,61 @@ describe('CalendarDayGrid', () => { expect(navigatedTo.getDate()).toBe(7); }); }); + + describe('week-row DOM element identity across month navigation', () => { + it('reuses the same DOM elements when navigating between months', () => { + // Regression test: a `key` on the inside CalendarGridRow that encoded the week's + // first-day date string caused React to unmount+remount the 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 DOM element across navigations — + // animations remain connected and can be replayed. + const { container, rerender } = render(); + 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(); + + 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 to be a direct child of and / + // to be direct children of . Any wrapper element would break a11y and CSS. + describe('motion wrappers preserve table structure', () => { + it('renders week rows as direct children of ', () => { + const { container } = render(); + 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 ', () => { + const { container } = render(); + // The header row contains the weekday label cells (Sun, Mon, …) + const headerCells = container.querySelectorAll('th[scope="col"]'); + expect(headerCells.length).toBeGreaterThan(0); + headerCells.forEach(cell => { + expect(cell.parentElement?.tagName).toBe('TR'); + }); + }); + }); }); diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.tsx index e1d5d0ec85ed8d..78d546b636bcef 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarDayGrid.tsx @@ -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; @@ -79,6 +81,7 @@ export const CalendarDayGrid: React.FunctionComponent = pr const weeks = useWeeks(props, onSelectDate, getSetRefCallback); const animateBackwards = useAnimateBackwards(weeks); + const [getWeekCornerStyles, calculateRoundedStyles] = useWeekCornerStyles(props); React.useImperativeHandle( @@ -134,7 +137,7 @@ export const CalendarDayGrid: React.FunctionComponent = pr showWeekNumbers, labelledBy, lightenDaysOutsideNavigatedMonth, - animationDirection, + animationDirection = AnimationDirection.Vertical, } = props; const classNames = useCalendarDayGridStyles_unstable({ @@ -160,6 +163,12 @@ export const CalendarDayGrid: React.FunctionComponent = 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 ( = pr > - - {weeks!.slice(1, weeks!.length - 1).map((week: DayInfo[], weekIndex: number) => ( + + + {weeks!.slice(1, weeks!.length - 1).map((week: DayInfo[], weekIndex: number) => ( + + + ))} - + + +
); diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarGridRow.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarGridRow.tsx index 19094c629041c6..8f481fc16a4f68 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarGridRow.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarGridRow.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import { getWeekNumbersInMonth } from '../../utils'; import { CalendarGridDayCell } from './CalendarGridDayCell'; @@ -28,7 +30,7 @@ export interface CalendarGridRowProps extends CalendarDayGridProps { /** * @internal */ -export const CalendarGridRow: React.FunctionComponent = props => { +export const CalendarGridRow = React.forwardRef((props, ref) => { const { ariaHidden, classNames, @@ -52,15 +54,9 @@ export const CalendarGridRow: React.FunctionComponent = pr : ''; return ( - + {showWeekNumbers && weekNumbers && ( - + {weekNumbers[weekIndex]} )} @@ -69,4 +65,6 @@ export const CalendarGridRow: React.FunctionComponent = pr ))} ); -}; +}); + +CalendarGridRow.displayName = 'CalendarGridRow'; diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarMonthHeaderRow.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarMonthHeaderRow.tsx index fbe0a3cb479f6d..e5dcb4468d23a1 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarMonthHeaderRow.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/CalendarMonthHeaderRow.tsx @@ -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'; @@ -41,16 +43,20 @@ export const CalendarMonthHeaderRow: React.FunctionComponent - {dayLabels[i]} - + // 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. + + + {dayLabels[i]} + + ); })} diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/useCalendarDayGridStyles.styles.ts b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/useCalendarDayGridStyles.styles.ts index 32a50053670904..2062decb3ed98a 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/useCalendarDayGridStyles.styles.ts +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarDayGrid/useCalendarDayGridStyles.styles.ts @@ -2,22 +2,6 @@ import { tokens } from '@fluentui/react-theme'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; -import { - DURATION_2, - DURATION_3, - EASING_FUNCTION_1, - EASING_FUNCTION_2, - FADE_IN, - FADE_OUT, - SLIDE_DOWN_IN20, - SLIDE_DOWN_OUT20, - SLIDE_LEFT_IN20, - SLIDE_RIGHT_IN20, - SLIDE_UP_IN20, - SLIDE_UP_OUT20, - TRANSITION_ROW_DISAPPEARANCE, -} from '../../utils'; -import { AnimationDirection } from '../Calendar/Calendar.types'; import { weekCornersClassNames } from './useWeekCornerStyles.styles'; import { createFocusOutlineStyle } from '@fluentui/react-tabster'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -180,32 +164,11 @@ const useWeekRowStyles = makeStyles({ zIndex: 1, }, }, - animation: { - animationDuration: DURATION_3, - animationFillMode: 'both', - animationTimingFunction: EASING_FUNCTION_1, - }, - horizontalBackward: { - animationName: [FADE_IN, SLIDE_RIGHT_IN20], - }, - horizontalForward: { - animationName: [FADE_IN, SLIDE_LEFT_IN20], - }, - verticalBackward: { - animationName: [FADE_IN, SLIDE_DOWN_IN20], - }, - verticalForward: { - animationName: [FADE_IN, SLIDE_UP_IN20], - }, }); const useWeekDayLabelCellStyles = makeStyles({ base: { userSelect: 'none', - animationDuration: DURATION_2, - animationFillMode: 'both', - animationName: FADE_IN, - animationTimingFunction: EASING_FUNCTION_2, }, }); @@ -315,35 +278,22 @@ const useDayTodayMarkerStyles = makeStyles({ const useFirstTransitionWeekStyles = makeStyles({ base: { - height: 0, + // Overlaid out of flow and transparent at rest; `pointerEvents: 'none'` stops the invisible + // overlay from intercepting clicks. `TransitionRowSlideOut` fades opacity 1 → 0, ending here. opacity: 0, overflow: 'hidden', - + pointerEvents: 'none', position: 'absolute', - width: 0, - }, - verticalForward: { - animationDuration: DURATION_3, - animationFillMode: 'both', - animationName: [FADE_OUT, SLIDE_UP_OUT20, TRANSITION_ROW_DISAPPEARANCE], - animationTimingFunction: EASING_FUNCTION_1, }, }); const useLastTransitionWeekStyles = makeStyles({ base: { - height: 0, marginTop: '-28px', opacity: 0, overflow: 'hidden', + pointerEvents: 'none', position: 'absolute', - width: 0, - }, - verticalBackward: { - animationDuration: DURATION_3, - animationFillMode: 'both', - animationName: [FADE_OUT, SLIDE_DOWN_OUT20, TRANSITION_ROW_DISAPPEARANCE], - animationTimingFunction: EASING_FUNCTION_1, }, }); @@ -410,7 +360,7 @@ export const useCalendarDayGridStyles_unstable = (props: CalendarDayGridStylePro const cornerBorderAndRadiusStyles = useCornerBorderAndRadiusStyles(); const dayTodayMarkerStyles = useDayTodayMarkerStyles(); - const { animateBackwards, animationDirection, lightenDaysOutsideNavigatedMonth, showWeekNumbers } = props; + const { lightenDaysOutsideNavigatedMonth, showWeekNumbers } = props; return { wrapper: mergeClasses(calendarDayGridClassNames.wrapper, wrapperStyles.base), @@ -427,19 +377,7 @@ export const useCalendarDayGridStyles_unstable = (props: CalendarDayGridStylePro ), daySelected: mergeClasses(calendarDayGridClassNames.daySelected, daySelectedStyles.base), daySingleSelected: mergeClasses(calendarDayGridClassNames.daySingleSelected, daySingleSelectedStyles.base), - weekRow: mergeClasses( - calendarDayGridClassNames.weekRow, - weekRowStyles.base, - animateBackwards !== undefined && weekRowStyles.animation, - animateBackwards !== undefined && - (animationDirection === AnimationDirection.Horizontal - ? animateBackwards - ? weekRowStyles.horizontalBackward - : weekRowStyles.horizontalForward - : animateBackwards - ? weekRowStyles.verticalBackward - : weekRowStyles.verticalForward), - ), + weekRow: mergeClasses(calendarDayGridClassNames.weekRow, weekRowStyles.base), weekDayLabelCell: mergeClasses(calendarDayGridClassNames.weekDayLabelCell, weekDayLabelCellStyles.base), weekNumberCell: mergeClasses(calendarDayGridClassNames.weekNumberCell, weekNumberCellStyles.base), dayOutsideBounds: mergeClasses(calendarDayGridClassNames.dayOutsideBounds, dayOutsideBoundsStyles.base), @@ -449,22 +387,8 @@ export const useCalendarDayGridStyles_unstable = (props: CalendarDayGridStylePro ), dayButton: mergeClasses(calendarDayGridClassNames.dayButton, dayButtonStyles.base), dayIsToday: mergeClasses(calendarDayGridClassNames.dayIsToday, dayIsTodayStyles.base), - firstTransitionWeek: mergeClasses( - calendarDayGridClassNames.firstTransitionWeek, - firstTransitionWeekStyles.base, - animateBackwards !== undefined && - animationDirection !== AnimationDirection.Horizontal && - !animateBackwards && - firstTransitionWeekStyles.verticalForward, - ), - lastTransitionWeek: mergeClasses( - calendarDayGridClassNames.lastTransitionWeek, - lastTransitionWeekStyles.base, - animateBackwards !== undefined && - animationDirection !== AnimationDirection.Horizontal && - animateBackwards && - lastTransitionWeekStyles.verticalBackward, - ), + firstTransitionWeek: mergeClasses(calendarDayGridClassNames.firstTransitionWeek, firstTransitionWeekStyles.base), + lastTransitionWeek: mergeClasses(calendarDayGridClassNames.lastTransitionWeek, lastTransitionWeekStyles.base), dayMarker: mergeClasses(calendarDayGridClassNames.dayMarker, dayMarkerStyles.base), dayTodayMarker: mergeClasses(calendarDayGridClassNames.dayTodayMarker, dayTodayMarkerStyles.base), }; diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.test.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.test.tsx new file mode 100644 index 00000000000000..e83b9c1b7d8b2f --- /dev/null +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.test.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { CalendarMonth } from './CalendarMonth'; +import { defaultNavigationIcons } from '../Calendar/calendarNavigationIcons'; +import { DEFAULT_CALENDAR_STRINGS } from '../../utils'; +import type { CalendarMonthProps } from './CalendarMonth.types'; + +const defaultProps: CalendarMonthProps = { + strings: DEFAULT_CALENDAR_STRINGS, + selectedDate: new Date(2025, 0, 15), + navigatedDate: new Date(2025, 0, 15), + onNavigateDate: jest.fn(), + navigationIcons: defaultNavigationIcons, +}; + +describe('CalendarMonth', () => { + it('should render without crashing', () => { + expect(() => render()).not.toThrow(); + }); + + describe('motion wrappers preserve grid structure', () => { + it('renders all month rows under the grid with role="row"', () => { + const { getByRole, getAllByRole } = render(); + const grid = getByRole('grid'); + const rows = getAllByRole('row'); + // 12 months laid out 4 per row → 3 rows + expect(rows.length).toBe(3); + rows.forEach(row => { + expect(grid.contains(row)).toBe(true); + }); + }); + + it('renders month buttons as gridcells inside rows', () => { + const { getAllByRole } = render(); + const cells = getAllByRole('gridcell'); + expect(cells.length).toBe(12); + cells.forEach(cell => { + expect(cell.parentElement?.getAttribute('role')).toBe('row'); + }); + }); + }); +}); diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.tsx index 05b5b33229bc23..f6c8e1c32f6d60 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarMonth/CalendarMonth.tsx @@ -18,6 +18,8 @@ import { CalendarYear } from '../CalendarYear/CalendarYear'; import { useCalendarMonthStyles_unstable } from './useCalendarMonthStyles.styles'; import type { CalendarMonthProps } from './CalendarMonth.types'; import type { CalendarYearRange, ICalendarYear } from '../CalendarYear/CalendarYear.types'; +import { DirectionalSlideIn } from '../../utils/calendarMotions'; +import { AnimationDirection } from '../../Calendar'; const MONTHS_PER_ROW = 4; @@ -74,7 +76,7 @@ function useFocusLogic({ componentRef }: { componentRef: CalendarMonthProps['com export const CalendarMonth: React.FunctionComponent = props => { const { allFocusable, - animationDirection, + animationDirection = AnimationDirection.Vertical, className, componentRef, dateTimeFormatter = DEFAULT_DATE_FORMATTING, @@ -253,43 +255,50 @@ export const CalendarMonth: React.FunctionComponent = props {rowIndexes.map((rowNum: number) => { const monthsForRow = strings!.shortMonths.slice(rowNum * MONTHS_PER_ROW, (rowNum + 1) * MONTHS_PER_ROW); return ( -
- {monthsForRow.map((month: string, index: number) => { - const monthIndex = rowNum * MONTHS_PER_ROW + index; - const indexedMonth = setMonth(navigatedDate, monthIndex); - const isNavigatedMonth = navigatedDate.getMonth() === monthIndex; - const isSelectedMonth = selectedDate.getMonth() === monthIndex; - const isSelectedYear = selectedDate.getFullYear() === navigatedDate.getFullYear(); - const isInBounds = - (minDate ? compareDatePart(minDate, getMonthEnd(indexedMonth)) < 1 : true) && - (maxDate ? compareDatePart(getMonthStart(indexedMonth), maxDate) < 1 : true); - - return ( - - ); - })} -
+ +
+ {monthsForRow.map((month: string, index: number) => { + const monthIndex = rowNum * MONTHS_PER_ROW + index; + const indexedMonth = setMonth(navigatedDate, monthIndex); + const isNavigatedMonth = navigatedDate.getMonth() === monthIndex; + const isSelectedMonth = selectedDate.getMonth() === monthIndex; + const isSelectedYear = selectedDate.getFullYear() === navigatedDate.getFullYear(); + const isInBounds = + (minDate ? compareDatePart(minDate, getMonthEnd(indexedMonth)) < 1 : true) && + (maxDate ? compareDatePart(getMonthStart(indexedMonth), maxDate) < 1 : true); + + return ( + + ); + })} +
+
); })} diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarPicker/useCalendarPickerStyles.styles.ts b/packages/react-components/react-calendar-compat/library/src/components/CalendarPicker/useCalendarPickerStyles.styles.ts index 499bb87850468a..b5adf04da37fda 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarPicker/useCalendarPickerStyles.styles.ts +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarPicker/useCalendarPickerStyles.styles.ts @@ -2,18 +2,6 @@ import { tokens } from '@fluentui/react-theme'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; -import { - DURATION_2, - DURATION_3, - EASING_FUNCTION_1, - EASING_FUNCTION_2, - FADE_IN, - SLIDE_DOWN_IN20, - SLIDE_LEFT_IN20, - SLIDE_RIGHT_IN20, - SLIDE_UP_IN20, -} from '../../utils/animations'; -import { AnimationDirection } from '../Calendar/Calendar.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { CalendarPickerStyles, CalendarPickerStyleProps } from './CalendarPicker.types'; @@ -69,12 +57,6 @@ const useCurrentItemButtonStyles = makeStyles({ padding: '0 4px 0 10px', textAlign: 'left', }, - animation: { - animationDuration: DURATION_2, - animationFillMode: 'both', - animationName: FADE_IN, - animationTimingFunction: EASING_FUNCTION_2, - }, hasHeaderClickCallback: { // If this is updated, make sure to update headerIsClickable in useCalendarDayStyles as well '&:hover': { @@ -145,23 +127,6 @@ const useButtonRowStyles = makeStyles({ marginBottom: 0, }, }, - animation: { - animationDuration: DURATION_3, - animationFillMode: 'both', - animationTimingFunction: EASING_FUNCTION_1, - }, - horizontalBackward: { - animationName: [FADE_IN, SLIDE_RIGHT_IN20], - }, - horizontalForward: { - animationName: [FADE_IN, SLIDE_LEFT_IN20], - }, - verticalBackward: { - animationName: [FADE_IN, SLIDE_DOWN_IN20], - }, - verticalForward: { - animationName: [FADE_IN, SLIDE_UP_IN20], - }, }); const useItemButtonStyles = makeStyles({ @@ -302,14 +267,7 @@ export const useCalendarPickerStyles_unstable = (props: CalendarPickerStyleProps const selectedStyles = useSelectedStyles(); const disabledStyles = useDisabledStyles(); - const { - animateBackwards, - animationDirection, - className, - hasHeaderClickCallback, - highlightCurrent, - highlightSelected, - } = props; + const { className, hasHeaderClickCallback, highlightCurrent, highlightSelected } = props; return { root: mergeClasses(calendarPickerClassNames.root, rootStyles.normalize, rootStyles.base, className), @@ -317,7 +275,6 @@ export const useCalendarPickerStyles_unstable = (props: CalendarPickerStyleProps currentItemButton: mergeClasses( calendarPickerClassNames.currentItemButton, currentItemButtonStyles.base, - animateBackwards !== undefined && currentItemButtonStyles.animation, hasHeaderClickCallback && currentItemButtonStyles.hasHeaderClickCallback, ), navigationButtonsContainer: mergeClasses( @@ -326,19 +283,7 @@ export const useCalendarPickerStyles_unstable = (props: CalendarPickerStyleProps ), navigationButton: mergeClasses(calendarPickerClassNames.navigationButton, navigationButtonStyles.base), gridContainer: mergeClasses(calendarPickerClassNames.gridContainer, gridContainerStyles.base), - buttonRow: mergeClasses( - calendarPickerClassNames.buttonRow, - buttonRowStyles.base, - buttonRowStyles.animation, - animateBackwards !== undefined && - (animationDirection === AnimationDirection.Horizontal - ? animateBackwards - ? buttonRowStyles.horizontalBackward - : buttonRowStyles.horizontalForward - : animateBackwards - ? buttonRowStyles.verticalBackward - : buttonRowStyles.verticalForward), - ), + buttonRow: mergeClasses(calendarPickerClassNames.buttonRow, buttonRowStyles.base), itemButton: mergeClasses(calendarPickerClassNames.itemButton, itemButtonStyles.base), selected: mergeClasses(calendarPickerClassNames.selected, highlightSelected && selectedStyles.highlightSelected), current: mergeClasses(calendarPickerClassNames.current, highlightCurrent && currentStyles.highlightCurrent), diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.test.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.test.tsx index 81b3676d199071..7226866b6a11f0 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.test.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.test.tsx @@ -96,4 +96,17 @@ describe('CalendarYear', () => { expect(onNavigateDate).not.toHaveBeenCalled(); }); }); + + describe('motion wrappers preserve grid structure', () => { + it('renders all year rows under the grid with role="row"', () => { + const { getByRole, getAllByRole } = render(); + const grid = getByRole('grid'); + const rows = getAllByRole('row'); + // CalendarYear lays out CELL_COUNT (12) cells across rows of 4 — expect 3 rows. + expect(rows.length).toBe(3); + rows.forEach(row => { + expect(grid.contains(row)).toBe(true); + }); + }); + }); }); diff --git a/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.tsx b/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.tsx index dfb82a8f649118..ffcf042561138d 100644 --- a/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.tsx +++ b/packages/react-components/react-calendar-compat/library/src/components/CalendarYear/CalendarYear.tsx @@ -5,6 +5,7 @@ import { Enter, Space } from '@fluentui/keyboard-keys'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; import { mergeClasses } from '@griffel/react'; import { useCalendarYearStyles_unstable } from './useCalendarYearStyles.styles'; +import { DirectionalSlideIn } from '../../utils/calendarMotions'; import type { CalendarYearStrings, CalendarYearProps, @@ -177,9 +178,16 @@ const CalendarYearGrid: React.FunctionComponent = props =
{cells.map((cellRow: React.ReactNode[], index: number) => { return ( -
- {cellRow} -
+ +
+ {cellRow} +
+
); })}
@@ -352,20 +360,18 @@ const CalendarYearHeader: React.FunctionComponent = pro }; CalendarYearHeader.displayName = 'CalendarYearHeader'; -function useAnimateBackwards({ selectedYear, navigatedYear }: CalendarYearProps) { - const rangeYear = selectedYear || navigatedYear || new Date().getFullYear(); - const fromYear = Math.floor(rangeYear / 10) * 10; - - const previousFromYearRef = React.useRef(fromYear); - React.useRef(() => { +function useAnimateBackwards(fromYear: number): boolean | undefined { + const previousFromYearRef = React.useRef(undefined); + React.useEffect(() => { previousFromYearRef.current = fromYear; }); // eslint-disable-next-line react-hooks/refs const previousFromYear = previousFromYearRef.current; // eslint-disable-next-line react-hooks/refs - if (!previousFromYear || previousFromYear === fromYear) { + if (previousFromYear === undefined || previousFromYear === fromYear) { return undefined; + // eslint-disable-next-line react-hooks/refs } else if (previousFromYear > fromYear) { return true; } else { @@ -406,8 +412,8 @@ function useYearRangeState({ selectedYear, navigatedYear, onNavigateDate }: Cale * @internal */ export const CalendarYear: React.FunctionComponent = props => { - const animateBackwards = useAnimateBackwards(props); const [fromYear, toYear, onNavNext, onNavPrevious] = useYearRangeState(props); + const animateBackwards = useAnimateBackwards(fromYear); const gridRef = React.useRef(null); diff --git a/packages/react-components/react-calendar-compat/library/src/utils/animations.ts b/packages/react-components/react-calendar-compat/library/src/utils/animations.ts index 6a45c738aa1d89..5c79ed10d910f7 100644 --- a/packages/react-components/react-calendar-compat/library/src/utils/animations.ts +++ b/packages/react-components/react-calendar-compat/library/src/utils/animations.ts @@ -1,10 +1,30 @@ -export const EASING_FUNCTION_1 = 'cubic-bezier(.1,.9,.2,1)'; +import { motionTokens } from '@fluentui/react-motion'; + +// === EASING FUNCTIONS === + +/** @deprecated Slide animations now use motion components. Use motionTokens.curveDecelerateMax instead. */ +export const EASING_FUNCTION_1 = motionTokens.curveDecelerateMax; + +/** @deprecated Fade animations now use Fade.In motion component. No exact motion token equivalent. */ export const EASING_FUNCTION_2 = 'cubic-bezier(.1,.25,.75,.9)'; -export const DURATION_1 = '0.167s'; -export const DURATION_2 = '0.267s'; -export const DURATION_3 = '0.367s'; -export const DURATION_4 = '0.467s'; +// === DURATIONS === + +/** @deprecated No longer used internally. */ +export const DURATION_1 = `${motionTokens.durationFast}ms`; + +/** @deprecated Fade animations now use Fade.In motion component with motionTokens.durationGentle. */ +export const DURATION_2 = `${motionTokens.durationGentle}ms`; + +/** @deprecated Slide animations now use motion components with motionTokens.durationSlower. */ +export const DURATION_3 = `${motionTokens.durationSlower}ms`; + +/** @deprecated No longer used internally. */ +export const DURATION_4 = `${motionTokens.durationUltraSlow}ms`; + +// === FADE ANIMATIONS === + +/** @deprecated Fade animations now use Fade.In motion component. */ export const FADE_IN = { from: { opacity: 0, @@ -13,6 +33,8 @@ export const FADE_IN = { opacity: 1, }, }; + +/** @deprecated Slide animations now use motion components. */ export const FADE_OUT = { from: { opacity: 1, @@ -22,6 +44,10 @@ export const FADE_OUT = { visibility: 'hidden' as const, }, }; + +// === SLIDE ANIMATIONS === + +/** @deprecated Slide-in animations now use the `DirectionalSlideIn` motion component. */ export const SLIDE_DOWN_IN20 = { from: { pointerEvents: 'none' as const, @@ -32,36 +58,44 @@ export const SLIDE_DOWN_IN20 = { transform: 'translate3d(0, 0, 0)', }, }; -export const SLIDE_LEFT_IN20 = { + +/** @deprecated Slide-in animations now use the `DirectionalSlideIn` motion component. */ +export const SLIDE_UP_IN20 = { from: { pointerEvents: 'none' as const, - transform: 'translate3d(20px, 0, 0)', + transform: 'translate3d(0, 20px, 0)', }, to: { pointerEvents: 'auto' as const, transform: 'translate3d(0, 0, 0)', }, }; -export const SLIDE_RIGHT_IN20 = { + +/** @deprecated Slide-in animations now use the `DirectionalSlideIn` motion component. */ +export const SLIDE_LEFT_IN20 = { from: { pointerEvents: 'none' as const, - transform: 'translate3d(-20px, 0, 0)', + transform: 'translate3d(20px, 0, 0)', }, to: { pointerEvents: 'auto' as const, transform: 'translate3d(0, 0, 0)', }, }; -export const SLIDE_UP_IN20 = { + +/** @deprecated Slide-in animations now use the `DirectionalSlideIn` motion component. */ +export const SLIDE_RIGHT_IN20 = { from: { pointerEvents: 'none' as const, - transform: 'translate3d(0, 20px, 0)', + transform: 'translate3d(-20px, 0, 0)', }, to: { pointerEvents: 'auto' as const, transform: 'translate3d(0, 0, 0)', }, }; + +/** @deprecated Slide-out animations now use the `DirectionalSlideOut` motion component. */ export const SLIDE_DOWN_OUT20 = { from: { transform: 'translate3d(0, 0, 0)', @@ -70,6 +104,8 @@ export const SLIDE_DOWN_OUT20 = { transform: 'translate3d(0, 20px, 0)', }, }; + +/** @deprecated Slide-out animations now use the `DirectionalSlideOut` motion component. */ export const SLIDE_UP_OUT20 = { from: { transform: 'translate3d(0, 0, 0)', @@ -79,11 +115,13 @@ export const SLIDE_UP_OUT20 = { }, }; +// === OTHER TRANSITIONS === + +/** @deprecated No longer used internally. */ export const TRANSITION_ROW_DISAPPEARANCE = { '100%': { height: '0px', overflow: 'hidden', - width: '0px', }, '99.9%': { diff --git a/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.test.tsx b/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.test.tsx new file mode 100644 index 00000000000000..e8224aed8a0d4b --- /dev/null +++ b/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.test.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { DirectionalSlideIn } from './calendarMotions'; +import { AnimationDirection } from '../Calendar'; + +describe('DirectionalSlideIn', () => { + it('renders its children with default props', () => { + const { getByTestId } = render( + +
content
+
, + ); + expect(getByTestId('child').textContent).toBe('content'); + }); + + it.each<[AnimationDirection, boolean]>([ + [AnimationDirection.Vertical, false], + [AnimationDirection.Vertical, true], + [AnimationDirection.Horizontal, false], + [AnimationDirection.Horizontal, true], + ])('renders children with animationDirection=%s, animateBackwards=%s', (animationDirection, animateBackwards) => { + const { getByTestId } = render( + +
content
+
, + ); + expect(getByTestId('child')).toBeTruthy(); + }); + + it('accepts custom duration and easing', () => { + const { getByTestId } = render( + +
content
+
, + ); + expect(getByTestId('child')).toBeTruthy(); + }); + + it('forwards its ref to the child element', () => { + const ref = React.createRef(); + const { getByTestId } = render( + +
content
+
, + ); + expect(ref.current).toBe(getByTestId('child')); + }); + + it('does not introduce a wrapper DOM element around the child', () => { + const { container, getByTestId } = render( + +
content
+
, + ); + expect(container.firstElementChild).toBe(getByTestId('child')); + }); + + it('preserves child DOM identity when replayKey changes (does not remount)', () => { + const { getByTestId, rerender } = render( + +
content
+
, + ); + const childBefore = getByTestId('child'); + + rerender( + +
content
+
, + ); + const childAfter = getByTestId('child'); + + expect(childAfter).toBe(childBefore); + }); + + it('preserves child DOM identity across replayKey changes within the same direction', () => { + const { getByTestId, rerender } = render( + +
content
+
, + ); + const childBefore = getByTestId('child'); + + rerender( + +
content
+
, + ); + + expect(getByTestId('child')).toBe(childBefore); + }); + + it('remounts the child when animateBackwards flips so the new slide direction takes effect', () => { + const { getByTestId, rerender } = render( + +
content
+
, + ); + const childBefore = getByTestId('child'); + + rerender( + +
content
+
, + ); + + expect(getByTestId('child')).not.toBe(childBefore); + }); +}); diff --git a/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.tsx b/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.tsx new file mode 100644 index 00000000000000..302f44fbef97a7 --- /dev/null +++ b/packages/react-components/react-calendar-compat/library/src/utils/calendarMotions.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { motionTokens, createMotionComponent } from '@fluentui/react-motion'; +import { Slide, fadeAtom, slideAtom } from '@fluentui/react-motion-components-preview'; +import * as React from 'react'; +import { getReactElementRef, useMergedRefs } from '@fluentui/react-utilities'; +import { AnimationDirection } from '../Calendar'; +import type { JSXElement } from '@fluentui/react-utilities'; + +// Distance the rows travel as they slide in/out. Shared so the enter and exit motions stay in sync. +const SLIDE_DISTANCE = '20px'; + +// Clones the single child with a ref that merges the forwarded ref and the child's own ref. Lets the +// `DirectionalSlideIn`/`DirectionalSlideOut` wrappers render no DOM of their own while still exposing a +// ref to the actual child element (e.g. the ``), keeping them transparent for table semantics. +function useChildWithMergedRef(children: JSXElement, ref: React.Ref) { + const child = React.Children.only(children) as React.ReactElement<{ ref?: React.Ref }>; + const mergedRef = useMergedRefs(ref, getReactElementRef(child)); + return React.cloneElement(child, { ref: mergedRef }); +} + +export type DirectionalSlideInProps = { + duration?: number; + easing?: string; + animationDirection?: AnimationDirection; + animateBackwards?: boolean; + /** + * When this value changes, the slide animation replays from the start on the same DOM element + * without remounting the subtree. Use this instead of a React `key` to retrigger the animation + * on navigation, so focus and DOM identity are preserved during keyboard navigation. + */ + replayKey?: string | number; + children: JSXElement; +}; + +// `forwardRef` per the repo rule banning `React.FC`. This wrapper renders no DOM of its own, +// so the forwarded ref is cloned onto the child below — callers get a ref to the actual +// ``/`
`, keeping `DirectionalSlideIn` transparent. +export const DirectionalSlideIn = React.forwardRef((props, ref) => { + const { + // Using durationSlower (400ms) as the closest token to the original 367ms + duration = motionTokens.durationSlower, + easing = motionTokens.curveDecelerateMax, + animationDirection = AnimationDirection.Vertical, + animateBackwards = false, + replayKey, + children, + } = props; + + let outX = '0px'; + let outY = '0px'; + const distance = animateBackwards ? `-${SLIDE_DISTANCE}` : SLIDE_DISTANCE; + if (animationDirection === AnimationDirection.Horizontal) { + outX = distance; + } else { + outY = distance; + } + + // `Slide.In`'s `useChildElement` will further merge its motion ref on top. + const childWithRef = useChildWithMergedRef(children, ref); + + // `Slide.In` bakes `outY` into its atoms at mount and `replayKey` only replays them, so a + // direction flip needs a remount. Same-direction navigations keep the key and reuse the DOM. + const directionKey = animateBackwards ? 'back' : 'fwd'; + + return ( + + {childWithRef} + + ); +}); + +DirectionalSlideIn.displayName = 'DirectionalSlideIn'; + +// One-way "out" motion for the day grid's transition (filler) rows. It fades and slides the row +// out in the navigation direction (the top row slides up, the bottom row slides down). The row is +// `position: absolute` and `opacity: 0` at rest, so the motion's forwards fill leaves it back at its +// hidden resting state once it finishes. Composed from Fluent's `fadeAtom` + `slideAtom` so it stays +// consistent with the rest of the motion system; there is no size animation, because the row is +// overlaid out of flow and hidden via opacity (not by collapsing its box). +const TransitionRowSlideOut = createMotionComponent(({ outY }: { outY: string }) => { + const duration = motionTokens.durationSlower; + const easing = motionTokens.curveDecelerateMax; + return [fadeAtom({ direction: 'exit', duration, easing }), slideAtom({ direction: 'exit', duration, easing, outY })]; +}); + +export type DirectionalSlideOutProps = { + /** Which transition row this wraps: the first (top) or last (bottom) filler row. */ + edge: 'first' | 'last'; + animationDirection?: AnimationDirection; + animateBackwards?: boolean; + /** + * When this value changes, the slide-out animation replays from the start on the same DOM element + * without remounting the subtree, matching {@link DirectionalSlideIn}. + */ + replayKey?: string | number; + children: JSXElement; +}; + +// `forwardRef` per the repo rule banning `React.FC`. Like `DirectionalSlideIn`, this wrapper renders +// no DOM of its own — the forwarded ref is cloned onto the child so callers get a ref to the actual +// ``, keeping the wrapper transparent for table semantics. +export const DirectionalSlideOut = React.forwardRef((props, ref) => { + const { edge, animationDirection = AnimationDirection.Vertical, animateBackwards, replayKey, children } = props; + + const childWithRef = useChildWithMergedRef(children, ref); + + // The legacy animation only ran for vertical navigation, and only on the row matching the + // navigation direction: the top (first) row slides up & out when navigating forward, the bottom + // (last) row slides down & out when navigating backward. `animateBackwards === undefined` means no + // navigation has happened yet, so nothing animates on initial mount (the row stays hidden at rest). + const isVertical = animationDirection !== AnimationDirection.Horizontal; + const shouldAnimate = + isVertical && animateBackwards !== undefined && (edge === 'first' ? !animateBackwards : animateBackwards); + + if (!shouldAnimate) { + return childWithRef; + } + + // First (top) row slides up and out; last (bottom) row slides down and out. + const outY = edge === 'first' ? `-${SLIDE_DISTANCE}` : SLIDE_DISTANCE; + + return ( + + {childWithRef} + + ); +}); + +DirectionalSlideOut.displayName = 'DirectionalSlideOut'; diff --git a/packages/react-components/react-calendar-compat/library/src/utils/index.ts b/packages/react-components/react-calendar-compat/library/src/utils/index.ts index 2cdf76e5e0414c..d021332767de7e 100644 --- a/packages/react-components/react-calendar-compat/library/src/utils/index.ts +++ b/packages/react-components/react-calendar-compat/library/src/utils/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- Re-exporting deprecated animations for backwards compatibility */ export { DURATION_1, DURATION_2, @@ -15,6 +16,7 @@ export { SLIDE_UP_OUT20, TRANSITION_ROW_DISAPPEARANCE, } from './animations'; +/* eslint-enable @typescript-eslint/no-deprecated */ export { DAYS_IN_WEEK, DateRangeType, DayOfWeek, FirstWeekOfYear, MonthOfYear, TimeConstants } from './constants'; export type { CalendarStrings, DateFormatting, DateGridStrings } from './dateFormatting'; export {