From 687c15b58b25bd374fe90b307b07e4101f71c829 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 17 Sep 2026 10:32:55 +0300 Subject: [PATCH] feat(react-calendar-preview): add month and year pickers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../library/src/CalendarMonth.ts | 23 ++ .../library/src/CalendarMonthGridCell.ts | 12 + .../library/src/CalendarMonthGridRow.ts | 13 + .../library/src/CalendarYear.ts | 24 ++ .../library/src/CalendarYearGridCell.ts | 12 + .../library/src/CalendarYearGridRow.ts | 13 + .../CalendarMonth/CalendarMonth.test.tsx | 110 +++++ .../CalendarMonth/CalendarMonth.tsx | 23 ++ .../CalendarMonth/CalendarMonth.types.ts | 163 ++++++++ .../src/components/CalendarMonth/index.ts | 19 + .../CalendarMonth/renderCalendarMonth.tsx | 37 ++ .../CalendarMonth/useCalendarMonth.tsx | 385 ++++++++++++++++++ .../useCalendarMonthContextValues.ts | 17 + .../useCalendarMonthStyles.styles.ts | 70 ++++ .../CalendarMonthGridCell.tsx | 21 + .../CalendarMonthGridCell.types.ts | 39 ++ .../components/CalendarMonthGridCell/index.ts | 12 + .../renderCalendarMonthGridCell.tsx | 14 + .../useCalendarMonthGridCell.ts | 69 ++++ .../useCalendarMonthGridCellStyles.styles.ts | 37 ++ .../CalendarMonthGridRow.tsx | 21 + .../CalendarMonthGridRow.types.ts | 48 +++ .../components/CalendarMonthGridRow/index.ts | 12 + .../renderCalendarMonthGridRow.tsx | 20 + .../useCalendarMonthGridRow.tsx | 70 ++++ .../useCalendarMonthGridRowStyles.styles.ts | 26 ++ .../CalendarYear/CalendarYear.test.tsx | 343 ++++++++++++++++ .../components/CalendarYear/CalendarYear.tsx | 22 + .../CalendarYear/CalendarYear.types.ts | 157 +++++++ .../src/components/CalendarYear/index.ts | 20 + .../CalendarYear/renderCalendarYear.tsx | 31 ++ .../CalendarYear/useCalendarYear.tsx | 278 +++++++++++++ .../useCalendarYearContextValues.ts | 20 + .../useCalendarYearStyles.styles.ts | 70 ++++ .../CalendarYearGridCell.tsx | 21 + .../CalendarYearGridCell.types.ts | 42 ++ .../components/CalendarYearGridCell/index.ts | 12 + .../renderCalendarYearGridCell.tsx | 14 + .../useCalendarYearGridCell.ts | 85 ++++ .../useCalendarYearGridCellStyles.styles.ts | 35 ++ .../CalendarYearGridRow.tsx | 21 + .../CalendarYearGridRow.types.ts | 54 +++ .../components/CalendarYearGridRow/index.ts | 12 + .../renderCalendarYearGridRow.tsx | 20 + .../useCalendarYearGridRow.tsx | 66 +++ .../useCalendarYearGridRowStyles.styles.ts | 26 ++ .../src/contexts/calendarMonthContext.ts | 86 ++++ .../src/contexts/calendarYearContext.ts | 95 +++++ .../hooks/useCalendarPickerStyles.styles.ts | 91 +++++ 49 files changed, 2931 insertions(+) create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarMonth.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarMonthGridCell.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarMonthGridRow.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarYear.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarYearGridCell.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/CalendarYearGridRow.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.test.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/renderCalendarMonth.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthContextValues.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/renderCalendarMonthGridCell.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/renderCalendarMonthGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRowStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.test.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/renderCalendarYear.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearContextValues.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/renderCalendarYearGridCell.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCellStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.types.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/index.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/renderCalendarYearGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRow.tsx create mode 100644 packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRowStyles.styles.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/contexts/calendarMonthContext.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/contexts/calendarYearContext.ts create mode 100644 packages/react-components/react-calendar-preview/library/src/hooks/useCalendarPickerStyles.styles.ts diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarMonth.ts b/packages/react-components/react-calendar-preview/library/src/CalendarMonth.ts new file mode 100644 index 00000000000000..f6fef51486394f --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarMonth.ts @@ -0,0 +1,23 @@ +export type { + CalendarMonthBaseProps, + CalendarMonthBaseState, + CalendarMonthCell, + CalendarMonthContextValue, + CalendarMonthContextValues, + CalendarMonthHandle, + CalendarMonthHeaderSelectData, + CalendarMonthNavigateData, + CalendarMonthProps, + CalendarMonthSelectData, + CalendarMonthSlots, + CalendarMonthState, +} from './components/CalendarMonth/index'; +export { + CalendarMonth, + calendarMonthClassNames, + renderCalendarMonth_unstable, + useCalendarMonthBase_unstable, + useCalendarMonthContextValues_unstable, + useCalendarMonth_unstable, + useCalendarMonthStyles_unstable, +} from './components/CalendarMonth/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridCell.ts b/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridCell.ts new file mode 100644 index 00000000000000..84d7315be943ff --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridCell.ts @@ -0,0 +1,12 @@ +export type { + CalendarMonthGridCellProps, + CalendarMonthGridCellSlots, + CalendarMonthGridCellState, +} from './components/CalendarMonthGridCell/index'; +export { + CalendarMonthGridCell, + calendarMonthGridCellClassNames, + renderCalendarMonthGridCell_unstable, + useCalendarMonthGridCellStyles_unstable, + useCalendarMonthGridCell_unstable, +} from './components/CalendarMonthGridCell/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridRow.ts b/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridRow.ts new file mode 100644 index 00000000000000..872131b7a3120c --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarMonthGridRow.ts @@ -0,0 +1,13 @@ +export type { + CalendarMonthGridRowProps, + CalendarMonthGridRowSlots, + CalendarMonthGridRowState, +} from './components/CalendarMonthGridRow/index'; +export { + CalendarMonthGridRow, + calendarMonthGridRowClassNames, + renderCalendarMonthGridRow_unstable, + useCalendarMonthGridRowBase_unstable, + useCalendarMonthGridRowStyles_unstable, + useCalendarMonthGridRow_unstable, +} from './components/CalendarMonthGridRow/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarYear.ts b/packages/react-components/react-calendar-preview/library/src/CalendarYear.ts new file mode 100644 index 00000000000000..62beee570e3caa --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarYear.ts @@ -0,0 +1,24 @@ +export type { + CalendarYearBaseProps, + CalendarYearBaseState, + CalendarYearCell, + CalendarYearContextValue, + CalendarYearContextValues, + CalendarYearHandle, + CalendarYearHeaderSelectData, + CalendarYearNavigateData, + CalendarYearProps, + CalendarYearRange, + CalendarYearSlots, + CalendarYearState, + CalendarYearSelectData, +} from './components/CalendarYear/index'; +export { + CalendarYear, + calendarYearClassNames, + renderCalendarYear_unstable, + useCalendarYearBase_unstable, + useCalendarYearContextValues_unstable, + useCalendarYear_unstable, + useCalendarYearStyles_unstable, +} from './components/CalendarYear/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarYearGridCell.ts b/packages/react-components/react-calendar-preview/library/src/CalendarYearGridCell.ts new file mode 100644 index 00000000000000..0becb0aaec3b56 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarYearGridCell.ts @@ -0,0 +1,12 @@ +export type { + CalendarYearGridCellProps, + CalendarYearGridCellSlots, + CalendarYearGridCellState, +} from './components/CalendarYearGridCell/index'; +export { + CalendarYearGridCell, + calendarYearGridCellClassNames, + renderCalendarYearGridCell_unstable, + useCalendarYearGridCellStyles_unstable, + useCalendarYearGridCell_unstable, +} from './components/CalendarYearGridCell/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/CalendarYearGridRow.ts b/packages/react-components/react-calendar-preview/library/src/CalendarYearGridRow.ts new file mode 100644 index 00000000000000..f0356022d777ea --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/CalendarYearGridRow.ts @@ -0,0 +1,13 @@ +export type { + CalendarYearGridRowProps, + CalendarYearGridRowSlots, + CalendarYearGridRowState, +} from './components/CalendarYearGridRow/index'; +export { + CalendarYearGridRow, + calendarYearGridRowClassNames, + renderCalendarYearGridRow_unstable, + useCalendarYearGridRowBase_unstable, + useCalendarYearGridRowStyles_unstable, + useCalendarYearGridRow_unstable, +} from './components/CalendarYearGridRow/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.test.tsx new file mode 100644 index 00000000000000..d6af2bbcbc2614 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.test.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { fireEvent, render as testingRender } from '@testing-library/react'; +import { CalendarMonth } from './CalendarMonth'; +import { CalendarProvider, calendarContextDefaultValue } from '../../contexts/calendarContext'; +import { calendarFormatters } from '../../utils'; +import type { CalendarContextValue } from '../../contexts/calendarContext'; +import type { CalendarMonthProps } from './CalendarMonth.types'; + +const defaultProps: CalendarMonthProps = { + navigatedDate: new Date(2025, 0, 15), + onNavigateDate: jest.fn(), +}; + +const render = (element: React.ReactElement, contextValue: Partial = {}) => + testingRender(element, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +type FormatDateTime = typeof calendarFormatters.dateTime; + +describe('CalendarMonth', () => { + it('should render without crashing', () => { + expect(() => render()).not.toThrow(); + }); + + it('keeps unavailable months focusable but not selectable with allFocusable', () => { + const onSelectDate = jest.fn(); + const { getByRole } = render(, { + allFocusable: true, + minDate: new Date(2025, 1, 1), + }); + const january = getByRole('gridcell', { name: 'January' }); + + expect(january).not.toBeDisabled(); + expect(january).toHaveAttribute('aria-disabled', 'true'); + expect(january).toHaveAttribute('tabindex', '0'); + fireEvent.click(january); + fireEvent.keyDown(january, { key: 'Enter' }); + expect(onSelectDate).not.toHaveBeenCalled(); + }); + + it('uses localized strings for the header and year navigation buttons', () => { + const dateTime: FormatDateTime = ({ date, format }) => `Localized ${calendarFormatters.dateTime({ date, format })}`; + const { getByRole } = render(, { + formatters: { + ...calendarFormatters, + dateTime, + monthPickerHeaderLabel: data => `Change the displayed year: ${data.formattedDate}`, + previousYearLabel: data => `Go to prior year ${data.formattedDate}`, + nextYearLabel: data => `Go to following year ${data.formattedDate}`, + }, + }); + + expect(getByRole('button', { name: 'Change the displayed year: Localized 2025' })).toBeTruthy(); + expect(getByRole('button', { name: 'Go to prior year Localized 2024' })).toHaveAttribute( + 'title', + 'Go to prior year Localized 2024', + ); + expect(getByRole('button', { name: 'Go to following year Localized 2026' })).toHaveAttribute( + 'title', + 'Go to following year Localized 2026', + ); + expect(getByRole('gridcell', { name: 'Localized January' })).toHaveTextContent('Localized Jan'); + }); + + it('passes localized range strings to the year picker', () => { + const { getAllByRole, getByRole } = render(, { + formatters: { + ...calendarFormatters, + monthPickerHeaderLabel: data => `Change the displayed year: ${data.formattedDate}`, + yearRangePickerHeaderLabel: data => `Change the displayed year: ${data.formattedRange}`, + previousYearRangeLabel: data => `Earlier years ${data.formattedRange}`, + nextYearRangeLabel: data => `Later years ${data.formattedRange}`, + }, + }); + + fireEvent.click(getByRole('button', { name: 'Change the displayed year: 2025' })); + + const buttons = getAllByRole('button'); + expect(getByRole('button', { name: 'Change the displayed year: 2025 - 2036' })).toBeTruthy(); + expect(buttons[1]).toHaveAttribute('title', 'Earlier years 2013 - 2024'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Later years 2037 - 2048'); + }); + + 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-preview/library/src/components/CalendarMonth/CalendarMonth.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.tsx new file mode 100644 index 00000000000000..d9338148463e42 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarMonth_unstable } from './useCalendarMonth'; +import { useCalendarMonthContextValues_unstable } from './useCalendarMonthContextValues'; +import { useCalendarMonthStyles_unstable } from './useCalendarMonthStyles.styles'; +import { renderCalendarMonth_unstable } from './renderCalendarMonth'; +import type { CalendarMonthHandle, CalendarMonthProps } from './CalendarMonth.types'; + +/** + * The month picker: a year header with navigation controls above a grid of months. Swaps itself + * for CalendarYear while the year picker is open. + */ +export const CalendarMonth = React.forwardRef((props, ref) => { + const state = useCalendarMonth_unstable(props, ref); + const contextValues = useCalendarMonthContextValues_unstable(state); + + useCalendarMonthStyles_unstable(state); + + return renderCalendarMonth_unstable(state, contextValues); +}); + +CalendarMonth.displayName = 'CalendarMonth'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.types.ts new file mode 100644 index 00000000000000..999fb1a9a72b39 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/CalendarMonth.types.ts @@ -0,0 +1,163 @@ +import type * as React from 'react'; +import type { ComponentProps, ComponentState, EventData, EventHandler, Slot } from '@fluentui/react-utilities'; +import type { CalendarMonthCell } from '../../contexts/calendarMonthContext'; +import type { CalendarYear } from '../CalendarYear'; +import type { CalendarYearHandle } from '../CalendarYear/CalendarYear.types'; + +export type { CalendarContextValue, CalendarContextValues } from '../../contexts/calendarContext'; + +export type { + CalendarMonthCell, + CalendarMonthContextValue, + CalendarMonthContextValues, +} from '../../contexts/calendarMonthContext'; + +/** + * The imperative API exposed on CalendarMonth's forwarded ref. + */ +export type CalendarMonthHandle = { + /** + * Moves focus to the navigated month, or to the year picker while it is open. + */ + focus(): void; +}; + +/** + * Defines the CalendarMonthSlots contract. + */ +export type CalendarMonthSlots = { + root: NonNullable>; + + /** + * Holds the year title and the navigation buttons. + */ + header: NonNullable>; + + /** + * The year label, which opens the year picker when it is not hidden. + */ + heading: NonNullable>; + + navigation: NonNullable>; + + previousYearButton: NonNullable>; + + nextYearButton: NonNullable>; + + /** + * The `role="grid"` container holding the month rows. + */ + grid: NonNullable>; + + /** + * The year picker, which replaces the whole month picker while it is open. Only rendered while + * `isYearPickerVisible` is set. + */ + yearPicker: NonNullable>; +}; + +/** + * Defines the CalendarMonthSelectData contract. + */ +export type CalendarMonthSelectData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + date: Date; + selectedDateRange: Date[]; +}; + +/** + * Defines the CalendarMonthNavigateData contract. + */ +export type CalendarMonthNavigateData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + date: Date; + focusOnNavigatedDay: boolean; +}; + +/** + * Defines the CalendarMonthHeaderSelectData contract. + */ +export type CalendarMonthHeaderSelectData = EventData<'click' | 'keydown', React.SyntheticEvent>; + +/** + * Defines the CalendarMonthProps contract. + */ +export type CalendarMonthProps = ComponentProps> & { + /** + * The currently navigated date + */ + navigatedDate?: Date; + + /** + * The date whose month is highlighted when `highlightSelectedMonth` is set, and whose year the + * year picker opens on. Defaults to the Calendar's selected value. + */ + selectedDate?: Date | null; + + /** + * Called when a month is selected. The date is an available day in that month and the range + * follows the CalendarProvider configuration. Year navigation does not select a month. + */ + onSelectDate?: EventHandler; + + /** + * Callback issued when the year is navigated + * @param date - The date that is navigated to + * @param focusOnNavigatedDay - Whether to set the focus to the navigated date. + */ + onNavigateDate?: EventHandler; + + /** + * Callback function when the header is selected + */ + onHeaderSelect?: EventHandler; + + /** + * Whether the year picker is hidden + * @default false + */ + yearPickerHidden?: boolean; +}; + +/** + * Defines the CalendarMonthState contract. + */ +export type CalendarMonthState = ComponentState & { + /** + * When the year picker is open, CalendarMonth renders CalendarYear instead of its own grid. + */ + isYearPickerVisible: boolean; + + yearPickerRef: React.RefObject; + + headerIsClickable: boolean; + + isPrevYearInBounds: boolean; + + isNextYearInBounds: boolean; + + /** + * Month cells grouped into rows of four. + */ + monthRows: CalendarMonthCell[][]; + + navigatedMonthRef: React.RefObject; + + /** + * The year the grid is showing, used to replay the row motions on navigation. + */ + navigatedYear: number; + + /** + * The formatted year shown in the header, also used as the grid's accessible name. + */ + yearString: string; +}; + +/** + * Row motion lives on CalendarMonthGridRow, so the base and styled layers share the same props. + */ +export type CalendarMonthBaseProps = CalendarMonthProps; + +/** + * Defines the CalendarMonthBaseState contract. + */ +export type CalendarMonthBaseState = CalendarMonthState; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/index.ts new file mode 100644 index 00000000000000..1bd32620b75054 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/index.ts @@ -0,0 +1,19 @@ +export { CalendarMonth } from './CalendarMonth'; +export { useCalendarMonthBase_unstable, useCalendarMonth_unstable } from './useCalendarMonth'; +export { useCalendarMonthContextValues_unstable } from './useCalendarMonthContextValues'; +export { renderCalendarMonth_unstable } from './renderCalendarMonth'; +export type { + CalendarMonthBaseProps, + CalendarMonthBaseState, + CalendarMonthCell, + CalendarMonthContextValue, + CalendarMonthContextValues, + CalendarMonthHandle, + CalendarMonthHeaderSelectData, + CalendarMonthNavigateData, + CalendarMonthProps, + CalendarMonthSelectData, + CalendarMonthSlots, + CalendarMonthState, +} from './CalendarMonth.types'; +export { calendarMonthClassNames, useCalendarMonthStyles_unstable } from './useCalendarMonthStyles.styles'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/renderCalendarMonth.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/renderCalendarMonth.tsx new file mode 100644 index 00000000000000..c84629122ca927 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/renderCalendarMonth.tsx @@ -0,0 +1,37 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import { CalendarMonthProvider } from '../../contexts/calendarMonthContext'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarMonthBaseState, CalendarMonthContextValues, CalendarMonthSlots } from './CalendarMonth.types'; + +/** + * Render the final JSX of CalendarMonth. + */ +export const renderCalendarMonth_unstable = ( + state: CalendarMonthBaseState, + contextValues: CalendarMonthContextValues, +): JSXElement => { + assertSlots(state); + + return ( + + + {state.isYearPickerVisible ? ( + + ) : ( + <> + + + + + + + + + + )} + + + ); +}; 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 new file mode 100644 index 00000000000000..158e7c6378f060 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonth.tsx @@ -0,0 +1,385 @@ +'use client'; + +import * as React from 'react'; +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'; +import { + addYears, + compareDatePart, + getDateRange, + getMonthEnd, + getMonthStart, + getYearEnd, + getYearStart, + isRestrictedDate, + setMonth, +} from '../../utils'; +import { useCalendarContext_unstable } from '../../contexts/calendarContext'; +import { CalendarYear } from '../CalendarYear/CalendarYear'; +import { CalendarMonthGridRow } from '../CalendarMonthGridRow/CalendarMonthGridRow'; +import type { CalendarYearHandle, CalendarYearProps, CalendarYearSelectData } from '../CalendarYear/CalendarYear.types'; +import type { + CalendarMonthBaseProps, + CalendarMonthBaseState, + CalendarMonthCell, + CalendarMonthHandle, + CalendarMonthProps, + CalendarMonthState, +} from './CalendarMonth.types'; + +const MONTHS_PER_ROW = 4; + +const noop = () => undefined; + +const onButtonKeyDown = + ( + callback?: (ev: React.KeyboardEvent) => void, + ): ((ev: React.KeyboardEvent) => void) => + ev => { + if (ev.key === Enter) { + ev.preventDefault(); + callback?.(ev); + } + }; + +/** + * 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. + */ +export const useCalendarMonthBase_unstable = ( + props: CalendarMonthBaseProps, + ref: React.Ref, +): CalendarMonthBaseState => { + const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable); + const dateRangeType = useCalendarContext_unstable(ctx => ctx.dateRangeType); + const firstDayOfWeek = useCalendarContext_unstable(ctx => ctx.firstDayOfWeek); + const formatters = useCalendarContext_unstable(ctx => ctx.formatters); + const highlightCurrentMonth = useCalendarContext_unstable(ctx => ctx.highlightCurrent); + const maxDate = useCalendarContext_unstable(ctx => ctx.maxDate); + const minDate = useCalendarContext_unstable(ctx => ctx.minDate); + const restrictedDates = useCalendarContext_unstable(ctx => ctx.restrictedDates); + const workWeekDays = useCalendarContext_unstable(ctx => ctx.workWeekDays); + const contextToday = useCalendarContext_unstable(ctx => ctx.today); + const value = useCalendarContext_unstable(ctx => ctx.value); + + const { + grid, + header, + navigation, + nextYearButton, + onHeaderSelect: onUserHeaderSelect, + onNavigateDate = noop, + onSelectDate, + previousYearButton, + heading, + yearPicker, + yearPickerHidden = false, + } = props; + + const today = React.useMemo(() => contextToday ?? new Date(), [contextToday]); + const navigatedDate = props.navigatedDate ?? today; + const selectedDate = props.selectedDate !== undefined ? props.selectedDate : value === undefined ? today : value; + + const navigatedMonthRef = React.useRef(null); + const yearPickerRef = React.useRef(null); + const focusOnUpdate = React.useRef(false); + + const [isYearPickerVisible, setIsYearPickerVisible] = React.useState(false); + + const focus = React.useCallback(() => { + if (yearPickerRef.current) { + yearPickerRef.current.focus(); + } else { + navigatedMonthRef.current?.focus(); + } + }, []); + + React.useImperativeHandle(ref, () => ({ focus }), [focus]); + + React.useEffect(() => { + if (focusOnUpdate.current) { + focus(); + focusOnUpdate.current = false; + } + }); + + const focusOnNextUpdate = () => { + focusOnUpdate.current = true; + }; + + const onSelectMonth = useEventCallback( + (ev: React.MouseEvent | React.KeyboardEvent, newMonth: number): void => { + const type = ev.type === 'keydown' ? 'keydown' : 'click'; + let date = setMonth(navigatedDate, newMonth); + if (onSelectDate) { + const availableDates = getDateRange(date, 'month', firstDayOfWeek).filter( + rangeDate => !isRestrictedDate(rangeDate, { minDate, maxDate, restrictedDates }), + ); + if (!availableDates.length) { + return; + } + date = availableDates.find(rangeDate => compareDatePart(rangeDate, date) === 0) ?? availableDates[0]; + const selectedDateRange = getDateRange(date, dateRangeType, firstDayOfWeek, workWeekDays).filter( + rangeDate => !isRestrictedDate(rangeDate, { minDate, maxDate, restrictedDates }), + ); + onSelectDate(ev, { event: ev, type, date, selectedDateRange }); + } + // If header is clickable the calendars are overlaid, switch back to day picker when month is clicked + onUserHeaderSelect?.(ev, { event: ev, type }); + onNavigateDate(ev, { + event: ev, + type, + date, + focusOnNavigatedDay: true, + }); + }, + ); + + const onSelectPrevYear = useEventCallback( + (ev: React.MouseEvent | React.KeyboardEvent) => + onNavigateDate(ev, { + event: ev, + type: ev.type === 'keydown' ? 'keydown' : 'click', + date: addYears(navigatedDate, -1), + focusOnNavigatedDay: false, + }), + ); + const onSelectNextYear = useEventCallback( + (ev: React.MouseEvent | React.KeyboardEvent) => + onNavigateDate(ev, { + event: ev, + type: ev.type === 'keydown' ? 'keydown' : 'click', + date: addYears(navigatedDate, 1), + focusOnNavigatedDay: false, + }), + ); + + const onHeaderSelect = useEventCallback( + (ev: React.MouseEvent | React.KeyboardEvent): void => { + if (!yearPickerHidden) { + focusOnNextUpdate(); + setIsYearPickerVisible(true); + } else { + onUserHeaderSelect?.(ev, { event: ev, type: ev.type === 'keydown' ? 'keydown' : 'click' }); + } + }, + ); + + const onSelectYear = useEventCallback((ev: React.SyntheticEvent | Event, data: CalendarYearSelectData) => { + const selectedYear = data.year; + focusOnNextUpdate(); + const navYear = navigatedDate.getFullYear(); + if (navYear !== selectedYear) { + let newNavigationDate = addYears(navigatedDate, selectedYear - navYear); + /* + * for min and max dates, adjust the new navigation date - perhaps this should be + * checked on the master navigation date handler (i.e. in Calendar) + */ + if (maxDate && compareDatePart(newNavigationDate, maxDate) > 0) { + newNavigationDate = setMonth(newNavigationDate, maxDate.getMonth()); + if (compareDatePart(newNavigationDate, maxDate) > 0) { + newNavigationDate = maxDate; + } + } else if (minDate && compareDatePart(newNavigationDate, minDate) < 0) { + newNavigationDate = setMonth(newNavigationDate, minDate.getMonth()); + if (compareDatePart(newNavigationDate, minDate) < 0) { + newNavigationDate = minDate; + } + } + onNavigateDate(ev, { + ...data, + date: newNavigationDate, + focusOnNavigatedDay: true, + }); + } + setIsYearPickerVisible(false); + }); + + const onYearPickerHeaderSelect = useEventCallback((_ev: React.SyntheticEvent | Event, _data): void => { + focusOnNextUpdate(); + setIsYearPickerVisible(false); + }); + + const yearString = formatters.dateTime({ date: navigatedDate, format: 'year' }); + const headerAriaLabel = formatters.monthPickerHeaderLabel({ date: navigatedDate, formattedDate: yearString }); + + const isPrevYearInBounds = minDate ? compareDatePart(minDate, getYearStart(navigatedDate)) < 0 : true; + const isNextYearInBounds = maxDate ? compareDatePart(getYearEnd(navigatedDate), maxDate) < 0 : true; + + 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; + const indexedMonth = setMonth(navigatedDate, monthIndex); + + return { + index: monthIndex, + label: formatters.dateTime({ date: indexedMonth, format: 'shortMonth' }), + ariaLabel: formatters.dateTime({ date: indexedMonth, format: 'month' }), + isNavigated: navigatedDate.getMonth() === monthIndex, + isCurrent: + !!highlightCurrentMonth && + today.getFullYear() === navigatedDate.getFullYear() && + today.getMonth() === monthIndex, + isSelected: + !!selectedDate && + selectedDate.getMonth() === monthIndex && + selectedDate.getFullYear() === navigatedDate.getFullYear(), + isInBounds: + (minDate ? compareDatePart(minDate, getMonthEnd(indexedMonth)) < 1 : true) && + (maxDate ? compareDatePart(getMonthStart(indexedMonth), maxDate) < 1 : true) && + (!onSelectDate || + getDateRange(indexedMonth, 'month', firstDayOfWeek).some( + date => !isRestrictedDate(date, { minDate, maxDate, restrictedDates }), + )), + onSelect: (ev: React.MouseEvent | React.KeyboardEvent) => + onSelectMonth(ev, monthIndex), + }; + }); + monthRows.push(row); + } + + let yearPickerProps: CalendarYearProps | undefined; + if (isYearPickerVisible) { + yearPickerProps = { + navigatedYear: navigatedDate.getFullYear(), + selectedYear: selectedDate?.getFullYear(), + onHeaderSelect: onYearPickerHeaderSelect, + onSelectYear, + }; + } + + return { + isYearPickerVisible, + monthRows, + navigatedMonthRef, + navigatedYear: navigatedDate.getFullYear(), + yearPickerRef, + yearString, + components: { + root: 'div', + header: 'div', + heading: 'button', + navigation: 'div', + previousYearButton: 'button', + nextYearButton: 'button', + grid: 'div', + yearPicker: 'div', + }, + root: slot.always(getIntrinsicElementProps('div', props), { elementType: 'div' }), + header: slot.always(header, { elementType: 'div' }), + heading: slot.always(heading, { + defaultProps: { + 'aria-label': headerAriaLabel, + children: ( + + {yearString} + + ), + onClick: onHeaderSelect, + onKeyDown: onButtonKeyDown(onHeaderSelect), + tabIndex: headerIsClickable ? 0 : -1, + type: 'button', + }, + elementType: 'button', + }), + navigation: slot.always(navigation, { elementType: 'div' }), + previousYearButton: slot.always(previousYearButton, { + defaultProps: { + 'aria-disabled': !isPrevYearInBounds, + onClick: isPrevYearInBounds ? onSelectPrevYear : undefined, + onKeyDown: isPrevYearInBounds ? onButtonKeyDown(onSelectPrevYear) : undefined, + tabIndex: isPrevYearInBounds ? undefined : allFocusable ? 0 : -1, + title: formatters.previousYearLabel({ + date: addYears(navigatedDate, -1), + formattedDate: formatters.dateTime({ date: addYears(navigatedDate, -1), format: 'year' }), + }), + type: 'button', + }, + elementType: 'button', + }), + nextYearButton: slot.always(nextYearButton, { + defaultProps: { + 'aria-disabled': !isNextYearInBounds, + onClick: isNextYearInBounds ? onSelectNextYear : undefined, + onKeyDown: isNextYearInBounds ? onButtonKeyDown(onSelectNextYear) : undefined, + tabIndex: isNextYearInBounds ? undefined : allFocusable ? 0 : -1, + title: formatters.nextYearLabel({ + date: addYears(navigatedDate, 1), + formattedDate: formatters.dateTime({ date: addYears(navigatedDate, 1), format: 'year' }), + }), + type: 'button', + }, + elementType: 'button', + }), + grid: slot.always(grid, { + defaultProps: { + 'aria-label': yearString, + role: 'grid', + }, + elementType: 'div', + }), + yearPicker: slot.always(yearPicker, { + defaultProps: yearPickerProps, + elementType: 'div', + }), + headerIsClickable, + isPrevYearInBounds, + isNextYearInBounds, + }; +}; + +/** + * Create the state required to render CalendarMonth. + */ +export const useCalendarMonth_unstable = ( + props: CalendarMonthProps, + ref: React.Ref, +): CalendarMonthState => { + const baseState = useCalendarMonthBase_unstable(props, ref); + const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' }); + const yearPicker = slot.always(props.yearPicker, { + defaultProps: baseState.yearPicker, + elementType: CalendarYear, + }); + yearPicker.ref = useMergedRefs(baseState.yearPickerRef, yearPicker.ref); + + return { + ...baseState, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...baseState.components, + yearPicker: CalendarYear, + }, + grid: slot.always(props.grid, { + defaultProps: { + ...baseState.grid, + ...arrowNavigationAttributes, + children: baseState.monthRows.map((_, rowNum: number) => ( + + )), + }, + elementType: 'div', + }), + yearPicker, + previousYearButton: slot.always(props.previousYearButton, { + defaultProps: { + ...baseState.previousYearButton, + children: , + }, + elementType: 'button', + }), + nextYearButton: slot.always(props.nextYearButton, { + defaultProps: { + ...baseState.nextYearButton, + children: , + }, + elementType: 'button', + }), + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthContextValues.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthContextValues.ts new file mode 100644 index 00000000000000..5ed09538a4d5b4 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthContextValues.ts @@ -0,0 +1,17 @@ +import type { CalendarMonthBaseState, CalendarMonthContextValues } from './CalendarMonth.types'; + +/** + * Not memoized: the month cells are rebuilt on every render, so a memo would capture stale + * closures without preventing any re-render. + */ +export function useCalendarMonthContextValues_unstable(state: CalendarMonthBaseState): CalendarMonthContextValues { + const { monthRows, navigatedMonthRef, navigatedYear } = state; + + return { + calendarMonth: { + monthRows, + navigatedMonthRef, + navigatedYear, + }, + }; +} diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthStyles.styles.ts new file mode 100644 index 00000000000000..53cc428ab12ef2 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonth/useCalendarMonthStyles.styles.ts @@ -0,0 +1,70 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import { useCalendarPickerStyles } from '../../hooks/useCalendarPickerStyles.styles'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarMonthSlots, CalendarMonthState } from './CalendarMonth.types'; + +/** + * The `yearPicker` and motion slots carry no class names of their own; the year picker owns its + * own styles hook. + */ +export const calendarMonthClassNames: SlotClassNames> = { + root: 'fui-CalendarMonth', + header: 'fui-CalendarMonth__header', + heading: 'fui-CalendarMonth__heading', + navigation: 'fui-CalendarMonth__navigation', + previousYearButton: 'fui-CalendarMonth__previousYearButton', + nextYearButton: 'fui-CalendarMonth__nextYearButton', + grid: 'fui-CalendarMonth__grid', +}; + +/** + * Apply styling to the CalendarMonth slots based on the state. + */ +export const useCalendarMonthStyles_unstable = (state: CalendarMonthState): CalendarMonthState => { + const pickerStyles = useCalendarPickerStyles(); + const itemStyles = useCalendarItemStyles(); + + /* eslint-disable react-hooks/immutability */ + state.root.className = mergeClasses( + calendarMonthClassNames.root, + pickerStyles.normalize, + pickerStyles.root, + state.root.className, + ); + + state.header.className = mergeClasses(calendarMonthClassNames.header, pickerStyles.header, state.header.className); + + state.heading.className = mergeClasses( + calendarMonthClassNames.heading, + pickerStyles.heading, + state.headerIsClickable && pickerStyles.hasHeaderClickCallback, + state.heading.className, + ); + + state.navigation.className = mergeClasses( + calendarMonthClassNames.navigation, + pickerStyles.navigation, + state.navigation.className, + ); + + state.grid.className = mergeClasses(calendarMonthClassNames.grid, pickerStyles.grid, state.grid.className); + + state.previousYearButton.className = mergeClasses( + calendarMonthClassNames.previousYearButton, + pickerStyles.navigationButton, + !state.isPrevYearInBounds && itemStyles.disabled, + state.previousYearButton.className, + ); + + state.nextYearButton.className = mergeClasses( + calendarMonthClassNames.nextYearButton, + pickerStyles.navigationButton, + !state.isNextYearInBounds && itemStyles.disabled, + state.nextYearButton.className, + ); + /* eslint-enable react-hooks/immutability */ + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.tsx new file mode 100644 index 00000000000000..70a46c7fd8643e --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarMonthGridCell_unstable } from './useCalendarMonthGridCell'; +import { useCalendarMonthGridCellStyles_unstable } from './useCalendarMonthGridCellStyles.styles'; +import { renderCalendarMonthGridCell_unstable } from './renderCalendarMonthGridCell'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { CalendarMonthGridCellProps } from './CalendarMonthGridCell.types'; + +/** + * A single month in the month picker grid. + */ +export const CalendarMonthGridCell: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCalendarMonthGridCell_unstable(props, ref); + + useCalendarMonthGridCellStyles_unstable(state); + + return renderCalendarMonthGridCell_unstable(state); +}); + +CalendarMonthGridCell.displayName = 'CalendarMonthGridCell'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.types.ts new file mode 100644 index 00000000000000..72f0f3bb3be513 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/CalendarMonthGridCell.types.ts @@ -0,0 +1,39 @@ +import type { ComponentState, ComponentProps, Slot } from '@fluentui/react-utilities'; +import type { CalendarMonthCell } from '../../contexts/calendarMonthContext'; + +/** + * Defines the CalendarMonthGridCellSlots contract. + */ +export type CalendarMonthGridCellSlots = { + /** + * The button representing a month in the grid. + */ + root: NonNullable>; +}; + +/** + * Defines the CalendarMonthGridCellProps contract. + */ +export type CalendarMonthGridCellProps = ComponentProps> & { + month: CalendarMonthCell; +}; + +/** + * Defines the CalendarMonthGridCellState contract. + */ +export type CalendarMonthGridCellState = ComponentState & { + /** + * Whether the month represented by this cell is the current month. + */ + isCurrent: boolean; + + /** + * Whether the month represented by this cell is within the allowed date bounds. + */ + isInBounds: boolean; + + /** + * Whether the month represented by this cell is currently selected. + */ + isSelected: boolean; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/index.ts new file mode 100644 index 00000000000000..a88665b7d7eb39 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/index.ts @@ -0,0 +1,12 @@ +export { CalendarMonthGridCell } from './CalendarMonthGridCell'; +export { useCalendarMonthGridCell_unstable } from './useCalendarMonthGridCell'; +export { renderCalendarMonthGridCell_unstable } from './renderCalendarMonthGridCell'; +export { + calendarMonthGridCellClassNames, + useCalendarMonthGridCellStyles_unstable, +} from './useCalendarMonthGridCellStyles.styles'; +export type { + CalendarMonthGridCellProps, + CalendarMonthGridCellSlots, + CalendarMonthGridCellState, +} from './CalendarMonthGridCell.types'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/renderCalendarMonthGridCell.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/renderCalendarMonthGridCell.tsx new file mode 100644 index 00000000000000..9a6b7859163fc0 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/renderCalendarMonthGridCell.tsx @@ -0,0 +1,14 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarMonthGridCellSlots, CalendarMonthGridCellState } from './CalendarMonthGridCell.types'; + +/** + * Render the final JSX of CalendarMonthGridCell. + */ +export const renderCalendarMonthGridCell_unstable = (state: CalendarMonthGridCellState): JSXElement => { + assertSlots(state); + + return ; +}; 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 new file mode 100644 index 00000000000000..fc3a54f3c629ac --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCell.ts @@ -0,0 +1,69 @@ +'use client'; + +import type * as React from 'react'; +import { Enter } from '@fluentui/keyboard-keys'; +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 type { ExtractSlotProps, Slot } from '@fluentui/react-utilities'; +import type { CalendarMonthGridCellProps, CalendarMonthGridCellState } from './CalendarMonthGridCell.types'; + +/** + * Create the state required to render CalendarMonthGridCell. + */ +export const useCalendarMonthGridCell_unstable = ( + props: CalendarMonthGridCellProps, + ref?: React.Ref, +): CalendarMonthGridCellState => { + const { month } = props; + const nativeProps = getIntrinsicElementProps('button', props, ['month']); + 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 root = slot.always( + { + ...nativeProps, + ref: rootRef, + 'data-current': stringifyDataAttribute(month.isCurrent), + 'data-selected': stringifyDataAttribute(month.isSelected), + 'data-outside-bounds': stringifyDataAttribute(!month.isInBounds), + children: month.label, + role: 'gridcell', + disabled: !allFocusable && !month.isInBounds, + onClick: event => { + nativeProps.onClick?.(event); + if (!event.isDefaultPrevented()) { + onSelect?.(event); + } + }, + 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, + tabIndex: month.isInBounds || allFocusable ? 0 : -1, + type: 'button', + } as ExtractSlotProps>, + { elementType: 'button' }, + ); + + Object.assign(root, {} satisfies Record); + + return { + components: { + root: 'button', + }, + isCurrent: month.isCurrent, + isInBounds: month.isInBounds, + isSelected: month.isSelected, + root, + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles.ts new file mode 100644 index 00000000000000..94d7920276a281 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles.ts @@ -0,0 +1,37 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import { useCalendarContext_unstable } from '../../contexts/calendarContext'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarMonthGridCellSlots, CalendarMonthGridCellState } from './CalendarMonthGridCell.types'; + +/** + * Class names for calendarMonthGridCell slots. + */ +export const calendarMonthGridCellClassNames: SlotClassNames = { + root: 'fui-CalendarMonthGridCell', +}; + +/** + * Apply styling to the CalendarMonthGridCell slots based on the state. + */ +export const useCalendarMonthGridCellStyles_unstable = ( + state: CalendarMonthGridCellState, +): CalendarMonthGridCellState => { + const itemStyles = useCalendarItemStyles(); + const highlightCurrentMonth = useCalendarContext_unstable(ctx => ctx.highlightCurrent); + const highlightSelectedMonth = useCalendarContext_unstable(ctx => ctx.highlightSelected); + + /* eslint-disable-next-line react-hooks/immutability */ + state.root.className = mergeClasses( + calendarMonthGridCellClassNames.root, + itemStyles.itemButton, + state.isCurrent && highlightCurrentMonth && itemStyles.highlightCurrent, + state.isSelected && highlightSelectedMonth && itemStyles.highlightSelected, + !state.isInBounds && itemStyles.disabled, + state.root.className, + ); + + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.tsx new file mode 100644 index 00000000000000..49ac71e2b376d2 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarMonthGridRow_unstable } from './useCalendarMonthGridRow'; +import { useCalendarMonthGridRowStyles_unstable } from './useCalendarMonthGridRowStyles.styles'; +import { renderCalendarMonthGridRow_unstable } from './renderCalendarMonthGridRow'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { CalendarMonthGridRowProps } from './CalendarMonthGridRow.types'; + +/** + * One row of the month picker grid, animated by the row motion the parent CalendarMonth publishes. + */ +export const CalendarMonthGridRow: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCalendarMonthGridRow_unstable(props, ref); + + useCalendarMonthGridRowStyles_unstable(state); + + return renderCalendarMonthGridRow_unstable(state); +}); + +CalendarMonthGridRow.displayName = 'CalendarMonthGridRow'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.types.ts new file mode 100644 index 00000000000000..72472ffc269daa --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/CalendarMonthGridRow.types.ts @@ -0,0 +1,48 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { MotionSlotProps } from '@fluentui/react-motion'; +import type { CalendarMonthCell } from '../../contexts/calendarMonthContext'; +import type { DirectionalSlideParams } from '../../utils/calendarMotions'; + +/** + * Defines the CalendarMonthGridRowSlots contract. + */ +export type CalendarMonthGridRowSlots = { + /** + * The `role="row"` element holding one row of month cells. + */ + root: NonNullable>; + + /** + * The motion slot wrapping the row. Absent on the unstyled layer, which renders the rows + * unanimated. Pass `null` to disable the animation. + */ + motion?: Slot>; +}; + +export type CalendarMonthGridRowBaseSlots = Omit; + +/** + * Defines the CalendarMonthGridRowProps contract. + */ +export type CalendarMonthGridRowProps = ComponentProps> & { + /** + * Index of the row within the month grid. Everything else is read from the CalendarMonth context. + */ + rowIndex: number; +}; + +export type CalendarMonthGridRowBaseProps = ComponentProps> & + Pick; + +/** + * Defines the CalendarMonthGridRowState contract. + */ +export type CalendarMonthGridRowState = ComponentState & { + /** + * The month cells in this row. + */ + cells: CalendarMonthCell[]; +}; + +export type CalendarMonthGridRowBaseState = ComponentState & + Pick; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/index.ts new file mode 100644 index 00000000000000..624f328671ce6c --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/index.ts @@ -0,0 +1,12 @@ +export { CalendarMonthGridRow } from './CalendarMonthGridRow'; +export { useCalendarMonthGridRowBase_unstable, useCalendarMonthGridRow_unstable } from './useCalendarMonthGridRow'; +export { renderCalendarMonthGridRow_unstable } from './renderCalendarMonthGridRow'; +export { + calendarMonthGridRowClassNames, + useCalendarMonthGridRowStyles_unstable, +} from './useCalendarMonthGridRowStyles.styles'; +export type { + CalendarMonthGridRowProps, + CalendarMonthGridRowSlots, + CalendarMonthGridRowState, +} from './CalendarMonthGridRow.types'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/renderCalendarMonthGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/renderCalendarMonthGridRow.tsx new file mode 100644 index 00000000000000..2cef8ff53c9206 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/renderCalendarMonthGridRow.tsx @@ -0,0 +1,20 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarMonthGridRowSlots, CalendarMonthGridRowState } from './CalendarMonthGridRow.types'; + +/** + * Render the final JSX of CalendarMonthGridRow. + */ +export const renderCalendarMonthGridRow_unstable = (state: CalendarMonthGridRowState): JSXElement => { + assertSlots(state); + + return state.motion ? ( + + + + ) : ( + + ); +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRow.tsx new file mode 100644 index 00000000000000..c668bef5aa0c49 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRow.tsx @@ -0,0 +1,70 @@ +'use client'; + +import * as React from 'react'; +import { motionSlot } from '@fluentui/react-motion'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { useCalendarMonthContext_unstable } from '../../contexts/calendarMonthContext'; +import { CalendarMonthGridCell } from '../CalendarMonthGridCell'; +import { DirectionalSlideIn } from '../../utils/calendarMotions'; +import { useAnimateBackwards } from '../../hooks/useAnimateBackwards'; +import type { + CalendarMonthGridRowBaseState, + CalendarMonthGridRowProps, + CalendarMonthGridRowState, +} from './CalendarMonthGridRow.types'; + +/** + * Create the base state required to render an unstyled CalendarMonthGridRow. + */ +export const useCalendarMonthGridRowBase_unstable = ( + props: CalendarMonthGridRowProps, + ref: React.Ref, +): CalendarMonthGridRowBaseState => { + const { rowIndex } = props; + const monthRows = useCalendarMonthContext_unstable(ctx => ctx.monthRows); + + return { + cells: monthRows[rowIndex] ?? [], + components: { + root: 'div', + }, + root: slot.always(getIntrinsicElementProps('div', { ref, role: 'row', ...props }, ['rowIndex']), { + elementType: 'div', + }), + }; +}; + +/** + * Create the state required to render CalendarMonthGridRow. + */ +export const useCalendarMonthGridRow_unstable = ( + props: CalendarMonthGridRowProps, + ref: React.Ref, +): CalendarMonthGridRowState => { + const state = useCalendarMonthGridRowBase_unstable(props, ref); + const navigatedYear = useCalendarMonthContext_unstable(ctx => ctx.navigatedYear); + const animateBackwards = useAnimateBackwards(navigatedYear); + + return { + ...state, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...state.components, + motion: DirectionalSlideIn, + }, + root: slot.always(state.root, { + defaultProps: { + children: state.cells.map(month => ), + }, + elementType: 'div', + }), + motion: motionSlot(props.motion, { + elementType: DirectionalSlideIn, + defaultProps: { + animationDirection: 'vertical', + animateBackwards, + replayKey: navigatedYear, + }, + }), + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRowStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRowStyles.styles.ts new file mode 100644 index 00000000000000..22afb88a8a42cb --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarMonthGridRow/useCalendarMonthGridRowStyles.styles.ts @@ -0,0 +1,26 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarMonthGridRowSlots, CalendarMonthGridRowState } from './CalendarMonthGridRow.types'; + +/** + * Class names for calendarMonthGridRow slots. + */ +export const calendarMonthGridRowClassNames: SlotClassNames = { + root: 'fui-CalendarMonthGridRow', + motion: 'fui-CalendarMonthGridRow__motion', +}; + +/** + * Apply styling to the CalendarMonthGridRow slots based on the state. + */ +export const useCalendarMonthGridRowStyles_unstable = (state: CalendarMonthGridRowState): CalendarMonthGridRowState => { + const itemStyles = useCalendarItemStyles(); + + /* eslint-disable react-hooks/immutability */ + state.root.className = mergeClasses(calendarMonthGridRowClassNames.root, itemStyles.buttonRow, state.root.className); + /* eslint-enable react-hooks/immutability */ + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.test.tsx new file mode 100644 index 00000000000000..d82a8b8c38a4ff --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.test.tsx @@ -0,0 +1,343 @@ +import * as React from 'react'; +import { render as testingRender, fireEvent } from '@testing-library/react'; +import { CalendarYear } from './CalendarYear'; +import type { CalendarYearHandle } from './CalendarYear.types'; +import { CalendarProvider, calendarContextDefaultValue } from '../../contexts/calendarContext'; +import { calendarFormatters } from '../../utils'; +import type { CalendarContextValue } from '../../contexts/calendarContext'; + +const CELL_COUNT = 12; + +const requiredProps = {}; + +const render = (element: React.ReactElement, contextValue: Partial = {}) => + testingRender(element, { + wrapper: ({ children }) => ( + {children} + ), + }); + +describe('CalendarYear', () => { + it('should render without crashing', () => { + expect(() => render()).not.toThrow(); + }); + + it('should render the navigated year range', () => { + const { getByRole } = render(); + const grid = getByRole('grid'); + // grid aria-label should contain the year range + expect(grid.getAttribute('aria-label')).toContain('2025'); + }); + + it('uses the provider today value and keeps unavailable years focusable without selecting them', () => { + const onSelectYear = jest.fn(); + const { getByRole, getByTitle } = render(, { + today: new Date(2025, 0, 15), + minDate: new Date(2026, 0, 1), + allFocusable: true, + }); + const year = getByRole('gridcell', { name: '2025' }); + + expect(year).toHaveAttribute('data-current'); + expect(year).not.toBeDisabled(); + expect(year).toHaveAttribute('aria-disabled', 'true'); + expect(year).toHaveAttribute('tabindex', '0'); + expect(getByTitle('Previous year range 2013 - 2024')).toHaveAttribute('tabindex', '0'); + fireEvent.click(year); + fireEvent.keyDown(year, { key: 'Enter' }); + expect(onSelectYear).not.toHaveBeenCalled(); + }); + + it('focuses the navigated year with an empty selection outside the current year range', () => { + const ref = React.createRef(); + const { getByRole } = render(, { + today: new Date(2026, 8, 8), + value: null, + }); + + ref.current?.focus(); + + expect(getByRole('gridcell', { name: '2027' })).toHaveFocus(); + }); + + it('focuses an available year when the navigated year is disabled', () => { + const ref = React.createRef(); + const { getByRole } = render(, { + minDate: new Date(2027, 0, 1), + maxDate: new Date(2030, 11, 31), + }); + + ref.current?.focus(); + + expect(getByRole('gridcell', { name: '2027' })).toHaveFocus(); + }); + + it.each([2000, 2045])('prefers the navigated year over selectedYear=%s for its range and focus', selectedYear => { + const ref = React.createRef(); + const { getByRole } = render(); + + ref.current?.focus(); + + expect(getByRole('grid')).toHaveAttribute('aria-label', '2040 - 2051'); + expect(getByRole('gridcell', { name: '2040' })).toHaveFocus(); + }); + + it('uses the selected year for range and focus when navigation is unspecified', () => { + const ref = React.createRef(); + const { getByRole } = render(); + + ref.current?.focus(); + + expect(getByRole('grid')).toHaveAttribute('aria-label', '2045 - 2056'); + expect(getByRole('gridcell', { name: '2045' })).toHaveFocus(); + }); + + it('updates selection without resetting an independently navigated range', () => { + const { getByRole, rerender } = render(); + + rerender(); + + expect(getByRole('grid')).toHaveAttribute('aria-label', '2040 - 2051'); + expect(getByRole('gridcell', { name: '2045' })).toHaveAttribute('aria-selected', 'true'); + }); + + it.each([false, true])('applies current-year styling only when highlightCurrent=%s', highlightCurrent => { + const { getByRole } = render(, { + today: new Date(2026, 8, 8), + value: null, + highlightCurrent, + }); + const currentYear = getByRole('gridcell', { name: '2026' }); + const otherYear = getByRole('gridcell', { name: '2027' }); + + expect(currentYear.className === otherYear.className).toBe(!highlightCurrent); + }); + + it.each([false, true])('keeps heading styles independent of current-year highlighting (clickable=%s)', clickable => { + const onHeaderSelect = clickable ? jest.fn() : undefined; + const renderPicker = (highlightCurrent: boolean) => ( + + + + ); + const { getByText, getByRole, rerender } = testingRender(renderPicker(false)); + const headingClassName = getByText('2025 - 2036').className; + const currentYearClassName = getByRole('gridcell', { name: '2026' }).className; + + rerender(renderPicker(true)); + + expect(getByText('2025 - 2036').className).toBe(headingClassName); + expect(getByRole('gridcell', { name: '2026' }).className).not.toBe(currentYearClassName); + }); + + it('should format visible years', () => { + const dateTime: typeof calendarFormatters.dateTime = ({ date, format }) => + format === 'year' + ? `Localized ${calendarFormatters.dateTime({ date, format })}` + : calendarFormatters.dateTime({ date, format }); + const { getByRole, getByText } = render(, { + formatters: { ...calendarFormatters, dateTime }, + }); + + expect(getByText('Localized 2025 - Localized 2036')).toBeTruthy(); + expect(getByRole('gridcell', { name: 'Localized 2025' })).toBeTruthy(); + }); + + it('preserves year selection with custom cell content', () => { + const onSelectYear = jest.fn(); + const { getByRole } = render( + FY {year}} onSelectYear={onSelectYear} />, + ); + + fireEvent.click(getByRole('gridcell', { name: 'FY 2027' })); + + expect(onSelectYear).toHaveBeenCalledTimes(1); + expect(onSelectYear.mock.calls[0][1].year).toBe(2027); + }); + + it('should format the current and adjacent ranges for accessible labels', () => { + const previousYearRangeLabel = jest.fn( + (data: { fromYear: number; toYear: number }) => `Previous ${data.fromYear} to ${data.toYear}`, + ); + const nextYearRangeLabel = jest.fn( + (data: { fromYear: number; toYear: number }) => `Next ${data.fromYear} to ${data.toYear}`, + ); + const yearRangePickerHeaderLabel = jest.fn( + (data: { formattedRange: string }) => `Choose a year from ${data.formattedRange}`, + ); + + const { getAllByRole, getByRole } = render(, { + formatters: { + ...calendarFormatters, + previousYearRangeLabel, + nextYearRangeLabel, + yearRangePickerHeaderLabel, + }, + }); + + const buttons = getAllByRole('button'); + expect(getByRole('grid')).toHaveAttribute('aria-label', '2025 - 2036'); + expect(getByRole('button', { name: 'Choose a year from 2025 - 2036' })).toBeTruthy(); + expect(buttons[1]).toHaveAttribute('title', 'Previous 2013 to 2024'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Next 2037 to 2048'); + expect(previousYearRangeLabel).toHaveBeenCalledWith({ + fromYear: 2013, + toYear: 2024, + formattedRange: '2013 - 2024', + }); + expect(nextYearRangeLabel).toHaveBeenCalledWith({ + fromYear: 2037, + toYear: 2048, + formattedRange: '2037 - 2048', + }); + expect(yearRangePickerHeaderLabel).toHaveBeenCalledWith({ + fromYear: 2025, + toYear: 2036, + formattedRange: '2025 - 2036', + }); + }); + + it('should update formatted range labels after navigating', () => { + const { getAllByRole } = render(, { + formatters: { + ...calendarFormatters, + previousYearRangeLabel: data => `Previous ${data.fromYear} to ${data.toYear}`, + nextYearRangeLabel: data => `Next ${data.fromYear} to ${data.toYear}`, + }, + }); + + let buttons = getAllByRole('button'); + fireEvent.click(buttons[buttons.length - 1]); + buttons = getAllByRole('button'); + + expect(buttons[0]).toHaveAttribute('title', 'Previous 2025 to 2036'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Next 2049 to 2060'); + }); + + it('should support static range labels', () => { + const { getAllByRole, getByRole } = render(, { + formatters: { + ...calendarFormatters, + yearRangePickerHeaderLabel: () => 'Localized range', + previousYearRangeLabel: () => 'Localized previous range', + nextYearRangeLabel: () => 'Localized next range', + }, + }); + + const buttons = getAllByRole('button'); + expect(getByRole('button', { name: 'Localized range' })).toBeTruthy(); + expect(buttons[1]).toHaveAttribute('title', 'Localized previous range'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Localized next range'); + }); + + it('should use default generated labels when formatters are not provided', () => { + const { getAllByRole } = render(); + + const buttons = getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('aria-label', '2025 - 2036, change year'); + expect(buttons[1]).toHaveAttribute('title', 'Previous year range 2013 - 2024'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Next year range 2037 - 2048'); + }); + + it('should prefer explicit slot labels over generated labels', () => { + const { getAllByRole, getByRole } = render( + , + ); + + const buttons = getAllByRole('button'); + expect(getByRole('button', { name: 'Custom heading label' })).toBeTruthy(); + expect(buttons[1]).toHaveAttribute('title', 'Custom previous label'); + expect(buttons[buttons.length - 1]).toHaveAttribute('title', 'Custom next label'); + expect(getByRole('grid')).toHaveAttribute('aria-label', 'Custom grid label'); + }); + + describe('onNavigateDate', () => { + it('should call onNavigateDate with the next decade fromYear when the next button is clicked', () => { + const onNavigateDate = jest.fn(); + const navigatedYear = 2025; + /* + * CalendarYear snaps fromYear to the navigatedYear (or selectedYear) + * CELL_COUNT = 12, so next decade starts at navigatedYear + 12 + */ + const { getAllByRole } = render( + , + ); + + // Navigation buttons: first is Previous, second is Next + const navButtons = getAllByRole('button'); + const nextButton = navButtons[navButtons.length - 1]; + fireEvent.click(nextButton); + + expect(onNavigateDate).toHaveBeenCalledTimes(1); + expect(onNavigateDate.mock.calls[0][1].year).toBe(navigatedYear + CELL_COUNT); + }); + + it('should call onNavigateDate with the previous decade fromYear when the previous button is clicked', () => { + const onNavigateDate = jest.fn(); + const navigatedYear = 2025; + const { getAllByRole } = render( + , + ); + + // Navigation buttons: first is Previous, second is Next + const navButtons = getAllByRole('button'); + const prevButton = navButtons[0]; + fireEvent.click(prevButton); + + expect(onNavigateDate).toHaveBeenCalledTimes(1); + expect(onNavigateDate.mock.calls[0][1].year).toBe(navigatedYear - CELL_COUNT); + }); + + it('should not call onNavigateDate when previous button is disabled due to minYear', () => { + const onNavigateDate = jest.fn(); + const navigatedYear = 2025; + // Previous is disabled when fromYear < minYear; with fromYear=2025, minYear=2026 disables it + const { getAllByRole } = render( + , + { minDate: new Date(navigatedYear + 1, 0, 1) }, + ); + + const navButtons = getAllByRole('button'); + const prevButton = navButtons[0]; + fireEvent.click(prevButton); + + expect(onNavigateDate).not.toHaveBeenCalled(); + }); + + it('should not call onNavigateDate when next button is disabled due to maxYear', () => { + const onNavigateDate = jest.fn(); + const navigatedYear = 2025; + // Next is disabled when fromYear + CELL_COUNT > maxYear; with fromYear=2025, maxYear=2036 disables it + const { getAllByRole } = render( + , + { maxDate: new Date(navigatedYear + CELL_COUNT - 1, 11, 31) }, + ); + + const navButtons = getAllByRole('button'); + const nextButton = navButtons[navButtons.length - 1]; + fireEvent.click(nextButton); + + 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-preview/library/src/components/CalendarYear/CalendarYear.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.tsx new file mode 100644 index 00000000000000..35d48f8912630f --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarYear_unstable } from './useCalendarYear'; +import { useCalendarYearContextValues_unstable } from './useCalendarYearContextValues'; +import { useCalendarYearStyles_unstable } from './useCalendarYearStyles.styles'; +import { renderCalendarYear_unstable } from './renderCalendarYear'; +import type { CalendarYearHandle, CalendarYearProps } from './CalendarYear.types'; + +/** + * The year picker: a year-range header with navigation controls above a grid of years. + */ +export const CalendarYear = React.forwardRef((props, ref) => { + const state = useCalendarYear_unstable(props, ref); + const contextValues = useCalendarYearContextValues_unstable(state); + + useCalendarYearStyles_unstable(state); + + return renderCalendarYear_unstable(state, contextValues); +}); + +CalendarYear.displayName = 'CalendarYear'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.types.ts new file mode 100644 index 00000000000000..f595e301e0ccf6 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/CalendarYear.types.ts @@ -0,0 +1,157 @@ +import type * as React from 'react'; +import type { ComponentProps, ComponentState, EventData, EventHandler, Slot } from '@fluentui/react-utilities'; +import type { CalendarYearCell } from '../../contexts/calendarYearContext'; + +export type { CalendarContextValue, CalendarContextValues } from '../../contexts/calendarContext'; + +export type { + CalendarYearCell, + CalendarYearContextValue, + CalendarYearContextValues, +} from '../../contexts/calendarYearContext'; + +/** + * The imperative API exposed on CalendarYear's forwarded ref. + */ +export type CalendarYearHandle = { + /** + * Focuses the navigated year, or the selected/current year when navigation is unspecified, + * within the focusable visible range. + */ + focus(): void; +}; + +/** + * Defines the CalendarYearRange contract. + */ +export interface CalendarYearRange { + fromYear: number; + toYear: number; +} + +/** + * Defines the CalendarYearSlots contract. + */ +export type CalendarYearSlots = { + root: NonNullable>; + + /** + * Holds the year range title and the navigation buttons. + */ + header: NonNullable>; + + /** + * The year range label. Renders as a `button` when `onHeaderSelect` is set, otherwise as a + * plain `div`. + */ + heading: NonNullable>; + + navigation: NonNullable>; + + previousRangeButton: NonNullable>; + + nextRangeButton: NonNullable>; + + /** + * The `role="grid"` container holding the year rows. + */ + grid: NonNullable>; +}; + +/** + * Defines the CalendarYearSelectData contract. + */ +export type CalendarYearSelectData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + year: number; +}; + +/** + * Defines the CalendarYearNavigateData contract. + */ +export type CalendarYearNavigateData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + year: number; +}; + +/** + * Defines the CalendarYearHeaderSelectData contract. + */ +export type CalendarYearHeaderSelectData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + focus: boolean; +}; + +/** + * Defines the CalendarYearProps contract. + */ +export type CalendarYearProps = ComponentProps> & { + /** + * The currently navigated year. Takes precedence over selectedYear for the displayed range and focus. + */ + navigatedYear?: number; + + /** + * The highlighted year. Initializes the displayed range and focus only when navigatedYear is omitted. + * Defaults to the year of the Calendar's selected value. + */ + selectedYear?: number; + + /** + * Renders the content of each year cell without replacing its selection and keyboard behavior. + */ + renderYear?: (year: number) => React.ReactNode; + + /** + * Callback action when a year is selected + * @param year - The year the user selected + */ + onSelectYear?: EventHandler; + + /** + * Callback action when the decade is navigated + * @param year - The year at the start of the new decade range being navigated to + */ + onNavigateDate?: EventHandler; + + /** + * Callback action when the header is selected + */ + onHeaderSelect?: EventHandler; +}; + +/** + * Defines the CalendarYearState contract. + */ +export type CalendarYearState = ComponentState & { + /** + * The first year of the visible range, used to replay the row motions on navigation. + */ + fromYear: number; + + hasHeaderClickCallback: boolean; + + onSelectYear: CalendarYearProps['onSelectYear']; + + prevDisabled: boolean; + + nextDisabled: boolean; + + /** + * Year cells grouped into rows of four. + */ + yearRows: CalendarYearCell[][]; + + currentYearRef: React.RefObject; + + selectedYearRef: React.RefObject; + + navigatedYearRef: React.RefObject; +}; + +/** + * Row motion lives on CalendarYearGridRow, so the base and styled layers share the same props. + */ +export type CalendarYearBaseProps = CalendarYearProps; + +/** + * Defines the CalendarYearBaseState contract. + */ +export type CalendarYearBaseState = CalendarYearState; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/index.ts new file mode 100644 index 00000000000000..6ac4a1431b0e71 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/index.ts @@ -0,0 +1,20 @@ +export { CalendarYear } from './CalendarYear'; +export { useCalendarYearBase_unstable, useCalendarYear_unstable } from './useCalendarYear'; +export { useCalendarYearContextValues_unstable } from './useCalendarYearContextValues'; +export { renderCalendarYear_unstable } from './renderCalendarYear'; +export type { + CalendarYearBaseProps, + CalendarYearBaseState, + CalendarYearCell, + CalendarYearContextValue, + CalendarYearContextValues, + CalendarYearHandle, + CalendarYearHeaderSelectData, + CalendarYearNavigateData, + CalendarYearProps, + CalendarYearRange, + CalendarYearSlots, + CalendarYearState, + CalendarYearSelectData, +} from './CalendarYear.types'; +export { calendarYearClassNames, useCalendarYearStyles_unstable } from './useCalendarYearStyles.styles'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/renderCalendarYear.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/renderCalendarYear.tsx new file mode 100644 index 00000000000000..42199825f6d595 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/renderCalendarYear.tsx @@ -0,0 +1,31 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import { CalendarYearProvider } from '../../contexts/calendarYearContext'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarYearBaseState, CalendarYearContextValues, CalendarYearSlots } from './CalendarYear.types'; + +/** + * Render the final JSX of CalendarYear. + */ +export const renderCalendarYear_unstable = ( + state: CalendarYearBaseState, + contextValues: CalendarYearContextValues, +): JSXElement => { + assertSlots(state); + + return ( + + + + + + + + + + + + + ); +}; 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 new file mode 100644 index 00000000000000..fbeed43160e5d7 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYear.tsx @@ -0,0 +1,278 @@ +'use client'; + +import * as React from 'react'; +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 { CalendarYearGridRow } from '../CalendarYearGridRow/CalendarYearGridRow'; +import type { + CalendarYearBaseProps, + CalendarYearBaseState, + CalendarYearCell, + CalendarYearHandle, + CalendarYearProps, + CalendarYearRange, + CalendarYearState, +} from './CalendarYear.types'; + +const CELL_COUNT = 12; +const CELLS_PER_ROW = 4; + +function useYearRangeState({ + selectedYear, + navigatedYear, + onNavigateDate, + currentYear, +}: Pick & { selectedYear?: number; currentYear: number }) { + const rangeYear = React.useMemo( + () => navigatedYear ?? selectedYear ?? Math.floor(currentYear / 10) * 10, + [currentYear, navigatedYear, selectedYear], + ); + + const [fromYear, setFromYear] = React.useState(rangeYear); + + const onNavNext = (ev: React.MouseEvent | React.KeyboardEvent) => { + const newFromYear = fromYear + CELL_COUNT; + setFromYear(newFromYear); + onNavigateDate?.(ev, { + event: ev, + type: ev.type === 'keydown' ? 'keydown' : 'click', + year: newFromYear, + }); + }; + + const onNavPrevious = (ev: React.MouseEvent | React.KeyboardEvent) => { + const newFromYear = fromYear - CELL_COUNT; + setFromYear(newFromYear); + onNavigateDate?.(ev, { + event: ev, + type: ev.type === 'keydown' ? 'keydown' : 'click', + year: newFromYear, + }); + }; + + React.useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFromYear(rangeYear); + }, [rangeYear]); + + return [fromYear, fromYear + CELL_COUNT - 1, onNavNext, onNavPrevious] as const; +} + +/** + * 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. + */ +export const useCalendarYearBase_unstable = ( + props: CalendarYearBaseProps, + ref: React.Ref, +): CalendarYearBaseState => { + const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable); + const contextToday = useCalendarContext_unstable(ctx => ctx.today); + const formatters = useCalendarContext_unstable(ctx => ctx.formatters); + const maxDate = useCalendarContext_unstable(ctx => ctx.maxDate); + const minDate = useCalendarContext_unstable(ctx => ctx.minDate); + const value = useCalendarContext_unstable(ctx => ctx.value); + + const { grid, header, navigation, nextRangeButton, onHeaderSelect, onSelectYear, previousRangeButton, heading } = + props; + + const today = contextToday ?? new Date(); + const currentYear = today.getFullYear(); + const selectedYear = props.selectedYear ?? (value ? value.getFullYear() : undefined); + const minYear = minDate ? minDate.getFullYear() : undefined; + const maxYear = maxDate ? maxDate.getFullYear() : undefined; + const [fromYear, toYear, onNavNext, onNavPrevious] = useYearRangeState({ + ...props, + currentYear, + selectedYear, + }); + + const currentYearRef = React.useRef(null); + const selectedYearRef = React.useRef(null); + const navigatedYearRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + focus() { + [navigatedYearRef.current, selectedYearRef.current, currentYearRef.current] + .find(element => element && !element.disabled) + ?.focus(); + }, + }), + [], + ); + + const formatYear = (year: number) => formatters.dateTime({ date: new Date(year, 0, 1), format: 'year' }); + + const firstFocusableYear = allFocusable ? fromYear : Math.max(fromYear, minYear ?? fromYear); + const lastFocusableYear = allFocusable ? toYear : Math.min(toYear, maxYear ?? toYear); + const focusYear = Math.min( + lastFocusableYear, + Math.max(firstFocusableYear, props.navigatedYear ?? selectedYear ?? currentYear), + ); + const yearRows: CalendarYearCell[][] = []; + for (let row = 0; row < CELL_COUNT / CELLS_PER_ROW; row++) { + const cells: CalendarYearCell[] = []; + 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), + isCurrent: year === currentYear, + isSelected: year === selectedYear, + isNavigated: year === focusYear, + isDisabled: (minYear !== undefined && year < minYear) || (maxYear !== undefined && year > maxYear), + }); + } + yearRows.push(cells); + } + + const prevDisabled = minYear !== undefined && fromYear <= minYear; + const nextDisabled = maxYear !== undefined && fromYear + CELL_COUNT > maxYear; + + const prevRange = { fromYear: fromYear - CELL_COUNT, toYear: toYear - CELL_COUNT }; + const nextRange = { fromYear: fromYear + CELL_COUNT, toYear: toYear + CELL_COUNT }; + + const formatRange = (range: CalendarYearRange) => `${formatYear(range.fromYear)} - ${formatYear(range.toYear)}`; + const range = { fromYear, toYear }; + const rangeLabel = formatRange(range); + const titleAriaLabel = formatters.yearRangePickerHeaderLabel({ ...range, formattedRange: rangeLabel }); + + const titleElementType = onHeaderSelect ? 'button' : 'div'; + const titleContent = ( + <> + {formatYear(fromYear)} - {formatYear(toYear)} + + ); + + return { + currentYearRef, + fromYear, + onSelectYear, + selectedYearRef, + navigatedYearRef, + yearRows, + components: { + root: 'div', + header: 'div', + heading: titleElementType, + navigation: 'div', + previousRangeButton: 'button', + nextRangeButton: 'button', + grid: 'div', + }, + root: slot.always(getIntrinsicElementProps('div', props), { elementType: 'div' }), + header: slot.always(header, { elementType: 'div' }), + heading: slot.always(heading, { + defaultProps: { + as: titleElementType, + 'aria-label': onHeaderSelect ? titleAriaLabel : undefined, + children: onHeaderSelect ? ( + + {titleContent} + + ) : ( + titleContent + ), + onClick: onHeaderSelect + ? (ev: React.MouseEvent) => onHeaderSelect(ev, { event: ev, type: 'click', focus: true }) + : undefined, + onKeyDown: onHeaderSelect + ? (ev: React.KeyboardEvent) => { + if (ev.key === Enter || ev.key === Space) { + ev.preventDefault(); + onHeaderSelect(ev, { event: ev, type: 'keydown', focus: true }); + } + } + : undefined, + type: onHeaderSelect ? 'button' : undefined, + }, + elementType: titleElementType, + }), + navigation: slot.always(navigation, { elementType: 'div' }), + previousRangeButton: slot.always(previousRangeButton, { + defaultProps: { + 'aria-disabled': prevDisabled, + onClick: prevDisabled ? undefined : onNavPrevious, + onKeyDown: prevDisabled ? undefined : onNavigationKeyDown(onNavPrevious), + tabIndex: prevDisabled ? (allFocusable ? 0 : -1) : undefined, + title: formatters.previousYearRangeLabel({ ...prevRange, formattedRange: formatRange(prevRange) }), + type: 'button', + }, + elementType: 'button', + }), + nextRangeButton: slot.always(nextRangeButton, { + defaultProps: { + 'aria-disabled': nextDisabled, + onClick: nextDisabled ? undefined : onNavNext, + onKeyDown: nextDisabled ? undefined : onNavigationKeyDown(onNavNext), + tabIndex: nextDisabled ? (allFocusable ? 0 : -1) : undefined, + title: formatters.nextYearRangeLabel({ ...nextRange, formattedRange: formatRange(nextRange) }), + type: 'button', + }, + elementType: 'button', + }), + grid: slot.always(grid, { + defaultProps: { + 'aria-label': rangeLabel, + role: 'grid', + }, + elementType: 'div', + }), + hasHeaderClickCallback: !!onHeaderSelect, + prevDisabled, + nextDisabled, + }; +}; + +const onNavigationKeyDown = + (callback: (ev: React.KeyboardEvent) => void) => (ev: React.KeyboardEvent) => { + if (ev.key === Enter) { + ev.preventDefault(); + callback(ev); + } + }; + +/** + * Create the state required to render CalendarYear. + */ +export const useCalendarYear_unstable = ( + props: CalendarYearProps, + ref: React.Ref, +): CalendarYearState => { + const baseState = useCalendarYearBase_unstable(props, ref); + const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'grid' }); + + return { + ...baseState, + grid: slot.always(props.grid, { + defaultProps: { + ...baseState.grid, + ...arrowNavigationAttributes, + children: baseState.yearRows.map((_, rowIndex: number) => ( + + )), + }, + elementType: 'div', + }), + previousRangeButton: slot.always(props.previousRangeButton, { + defaultProps: { + ...baseState.previousRangeButton, + children: , + }, + elementType: 'button', + }), + nextRangeButton: slot.always(props.nextRangeButton, { + defaultProps: { + ...baseState.nextRangeButton, + children: , + }, + elementType: 'button', + }), + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearContextValues.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearContextValues.ts new file mode 100644 index 00000000000000..21183460087641 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearContextValues.ts @@ -0,0 +1,20 @@ +import type { CalendarYearBaseState, CalendarYearContextValues } from './CalendarYear.types'; + +/** + * Not memoized: the year cells are rebuilt on every render, so a memo would capture stale closures + * without preventing any re-render. + */ +export function useCalendarYearContextValues_unstable(state: CalendarYearBaseState): CalendarYearContextValues { + const { fromYear, currentYearRef, navigatedYearRef, onSelectYear, selectedYearRef, yearRows } = state; + + return { + calendarYear: { + currentYearRef, + navigatedYearRef, + onSelectYear, + selectedYearRef, + yearRows, + fromYear, + }, + }; +} diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearStyles.styles.ts new file mode 100644 index 00000000000000..67191017ac228e --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYear/useCalendarYearStyles.styles.ts @@ -0,0 +1,70 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import { useCalendarPickerStyles } from '../../hooks/useCalendarPickerStyles.styles'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarYearSlots, CalendarYearState } from './CalendarYear.types'; + +/** + * The motion slot carries no class names of its own; the per-year class names belong to + * CalendarYearGridRow and CalendarYearGridCell, each of which owns its own styles hook. + */ +export const calendarYearClassNames: SlotClassNames = { + root: 'fui-CalendarYear', + header: 'fui-CalendarYear__header', + heading: 'fui-CalendarYear__heading', + navigation: 'fui-CalendarYear__navigation', + previousRangeButton: 'fui-CalendarYear__previousRangeButton', + nextRangeButton: 'fui-CalendarYear__nextRangeButton', + grid: 'fui-CalendarYear__grid', +}; + +/** + * Apply styling to the CalendarYear slots based on the state. + */ +export const useCalendarYearStyles_unstable = (state: CalendarYearState): CalendarYearState => { + const pickerStyles = useCalendarPickerStyles(); + const itemStyles = useCalendarItemStyles(); + + /* eslint-disable react-hooks/immutability */ + state.root.className = mergeClasses( + calendarYearClassNames.root, + pickerStyles.normalize, + pickerStyles.root, + state.root.className, + ); + + state.header.className = mergeClasses(calendarYearClassNames.header, pickerStyles.header, state.header.className); + + state.navigation.className = mergeClasses( + calendarYearClassNames.navigation, + pickerStyles.navigation, + state.navigation.className, + ); + + state.grid.className = mergeClasses(calendarYearClassNames.grid, pickerStyles.grid, state.grid.className); + + state.heading.className = mergeClasses( + calendarYearClassNames.heading, + pickerStyles.heading, + state.hasHeaderClickCallback && pickerStyles.hasHeaderClickCallback, + state.heading.className, + ); + + state.previousRangeButton.className = mergeClasses( + calendarYearClassNames.previousRangeButton, + pickerStyles.navigationButton, + state.prevDisabled && itemStyles.disabled, + state.previousRangeButton.className, + ); + + state.nextRangeButton.className = mergeClasses( + calendarYearClassNames.nextRangeButton, + pickerStyles.navigationButton, + state.nextDisabled && itemStyles.disabled, + state.nextRangeButton.className, + ); + /* eslint-enable react-hooks/immutability */ + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.tsx new file mode 100644 index 00000000000000..b757177bf14482 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarYearGridCell_unstable } from './useCalendarYearGridCell'; +import { useCalendarYearGridCellStyles_unstable } from './useCalendarYearGridCellStyles.styles'; +import { renderCalendarYearGridCell_unstable } from './renderCalendarYearGridCell'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { CalendarYearGridCellProps } from './CalendarYearGridCell.types'; + +/** + * A single year in the year picker grid. + */ +export const CalendarYearGridCell: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCalendarYearGridCell_unstable(props, ref); + + useCalendarYearGridCellStyles_unstable(state); + + return renderCalendarYearGridCell_unstable(state); +}); + +CalendarYearGridCell.displayName = 'CalendarYearGridCell'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.types.ts new file mode 100644 index 00000000000000..2097058b17c326 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/CalendarYearGridCell.types.ts @@ -0,0 +1,42 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { CalendarYearCell } from '../../contexts/calendarYearContext'; + +/** + * Defines the CalendarYearGridCellSlots contract. + */ +export type CalendarYearGridCellSlots = { + /** + * The button representing a year in the grid. + */ + root: NonNullable>; +}; + +/** + * Defines the CalendarYearGridCellProps contract. + */ +export type CalendarYearGridCellProps = ComponentProps> & { + /** + * The calendar year cell data associated with this grid cell. + */ + cell: CalendarYearCell; +}; + +/** + * Defines the CalendarYearGridCellState contract. + */ +export type CalendarYearGridCellState = ComponentState & { + /** + * Whether the year represented by this cell is the current year. + */ + isCurrent: boolean; + + /** + * Whether the year represented by this cell is disabled. + */ + isDisabled: boolean; + + /** + * Whether the year represented by this cell is selected. + */ + isSelected: boolean; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/index.ts new file mode 100644 index 00000000000000..460d1d04e9fb41 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/index.ts @@ -0,0 +1,12 @@ +export { CalendarYearGridCell } from './CalendarYearGridCell'; +export { useCalendarYearGridCell_unstable } from './useCalendarYearGridCell'; +export { renderCalendarYearGridCell_unstable } from './renderCalendarYearGridCell'; +export { + calendarYearGridCellClassNames, + useCalendarYearGridCellStyles_unstable, +} from './useCalendarYearGridCellStyles.styles'; +export type { + CalendarYearGridCellProps, + CalendarYearGridCellSlots, + CalendarYearGridCellState, +} from './CalendarYearGridCell.types'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/renderCalendarYearGridCell.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/renderCalendarYearGridCell.tsx new file mode 100644 index 00000000000000..b0d95a1b0c22e2 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/renderCalendarYearGridCell.tsx @@ -0,0 +1,14 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarYearGridCellSlots, CalendarYearGridCellState } from './CalendarYearGridCell.types'; + +/** + * Render the final JSX of CalendarYearGridCell. + */ +export const renderCalendarYearGridCell_unstable = (state: CalendarYearGridCellState): JSXElement => { + assertSlots(state); + + return ; +}; 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 new file mode 100644 index 00000000000000..3e8cccdc0a7db4 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCell.ts @@ -0,0 +1,85 @@ +'use client'; + +import type * as React from 'react'; +import { Enter } from '@fluentui/keyboard-keys'; +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 type { ExtractSlotProps, Slot } from '@fluentui/react-utilities'; +import type { CalendarYearGridCellProps, CalendarYearGridCellState } from './CalendarYearGridCell.types'; + +/** + * Create the state required to render CalendarYearGridCell. + */ +export const useCalendarYearGridCell_unstable = ( + props: CalendarYearGridCellProps, + ref: React.Ref, +): CalendarYearGridCellState => { + const { cell } = props; + const nativeProps = getIntrinsicElementProps('button', props, ['cell']); + const allFocusable = useCalendarContext_unstable(ctx => ctx.allFocusable); + const currentYearRef = useCalendarYearContext_unstable(ctx => ctx.currentYearRef); + const navigatedYearRef = useCalendarYearContext_unstable(ctx => ctx.navigatedYearRef); + const onSelectYear = useCalendarYearContext_unstable(ctx => ctx.onSelectYear); + const selectedYearRef = useCalendarYearContext_unstable(ctx => ctx.selectedYearRef); + const cellRef = useMergedRefs( + ref, + cell.isSelected ? selectedYearRef : undefined, + cell.isCurrent ? currentYearRef : undefined, + cell.isNavigated ? navigatedYearRef : undefined, + ); + + const onSelect = cell.isDisabled + ? undefined + : (event: React.MouseEvent | React.KeyboardEvent) => + onSelectYear?.(event, { + event, + type: event.type === 'keydown' ? 'keydown' : 'click', + year: cell.year, + }); + + const root = slot.always>>( + { + ...nativeProps, + ref: cellRef, + children: cell.content, + type: 'button', + role: 'gridcell', + onClick: event => { + nativeProps.onClick?.(event); + if (!event.isDefaultPrevented()) { + onSelect?.(event); + } + }, + 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, + 'aria-selected': cell.isSelected, + }, + { elementType: 'button' }, + ); + + Object.assign(root, { + 'data-current': stringifyDataAttribute(cell.isCurrent), + 'data-selected': stringifyDataAttribute(cell.isSelected), + 'data-outside-bounds': stringifyDataAttribute(cell.isDisabled), + } satisfies Record); + + return { + components: { + root: 'button', + }, + isCurrent: cell.isCurrent, + isDisabled: cell.isDisabled, + isSelected: cell.isSelected, + root, + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCellStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCellStyles.styles.ts new file mode 100644 index 00000000000000..fd92680a6cc22e --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridCell/useCalendarYearGridCellStyles.styles.ts @@ -0,0 +1,35 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import { useCalendarContext_unstable } from '../../contexts/calendarContext'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarYearGridCellSlots, CalendarYearGridCellState } from './CalendarYearGridCell.types'; + +/** + * Class names for calendarYearGridCell slots. + */ +export const calendarYearGridCellClassNames: SlotClassNames = { + root: 'fui-CalendarYearGridCell', +}; + +/** + * Apply styling to the CalendarYearGridCell slots based on the state. + */ +export const useCalendarYearGridCellStyles_unstable = (state: CalendarYearGridCellState): CalendarYearGridCellState => { + const itemStyles = useCalendarItemStyles(); + const highlightCurrentYear = useCalendarContext_unstable(ctx => ctx.highlightCurrent); + const highlightSelectedYear = useCalendarContext_unstable(ctx => ctx.highlightSelected); + + /* eslint-disable-next-line react-hooks/immutability */ + state.root.className = mergeClasses( + calendarYearGridCellClassNames.root, + itemStyles.itemButton, + state.isCurrent && highlightCurrentYear && itemStyles.highlightCurrent, + state.isSelected && highlightSelectedYear && itemStyles.highlightSelected, + state.isDisabled && itemStyles.disabled, + state.root.className, + ); + + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.tsx new file mode 100644 index 00000000000000..de74f508fdb636 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import { useCalendarYearGridRow_unstable } from './useCalendarYearGridRow'; +import { useCalendarYearGridRowStyles_unstable } from './useCalendarYearGridRowStyles.styles'; +import { renderCalendarYearGridRow_unstable } from './renderCalendarYearGridRow'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { CalendarYearGridRowProps } from './CalendarYearGridRow.types'; + +/** + * One row of the year picker grid, animated by the row motion the parent CalendarYear publishes. + */ +export const CalendarYearGridRow: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCalendarYearGridRow_unstable(props, ref); + + useCalendarYearGridRowStyles_unstable(state); + + return renderCalendarYearGridRow_unstable(state); +}); + +CalendarYearGridRow.displayName = 'CalendarYearGridRow'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.types.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.types.ts new file mode 100644 index 00000000000000..8daf6fb7ec2a72 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/CalendarYearGridRow.types.ts @@ -0,0 +1,54 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { MotionSlotProps } from '@fluentui/react-motion'; +import type { CalendarYearCell } from '../../contexts/calendarYearContext'; +import type { DirectionalSlideParams } from '../../utils/calendarMotions'; + +/** + * Defines the CalendarYearGridRowSlots contract. + */ +export type CalendarYearGridRowSlots = { + /** + * The `role="row"` element holding one row of year cells. + */ + root: NonNullable>; + + /** + * The motion slot wrapping the row. Absent on the unstyled layer, which renders the rows + * unanimated. Pass `null` to disable the animation. + */ + motion?: Slot>; +}; + +export type CalendarYearGridRowBaseSlots = Omit; + +/** + * Defines the CalendarYearGridRowProps contract. + */ +export type CalendarYearGridRowProps = ComponentProps> & { + /** + * Index of the row within the year grid. Everything else is read from the CalendarYear context. + */ + rowIndex: number; +}; + +/** + * Defines the CalendarYearGridRowBaseProps contract. + */ +export type CalendarYearGridRowBaseProps = ComponentProps> & + Pick; + +/** + * Defines the CalendarYearGridRowState contract. + */ +export type CalendarYearGridRowState = ComponentState & { + /** + * The year cells in this row. + */ + cells: CalendarYearCell[]; +}; + +/** + * Defines the CalendarYearGridRowBaseState contract. + */ +export type CalendarYearGridRowBaseState = ComponentState & + Pick; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/index.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/index.ts new file mode 100644 index 00000000000000..62b48a2c9172a3 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/index.ts @@ -0,0 +1,12 @@ +export { CalendarYearGridRow } from './CalendarYearGridRow'; +export { useCalendarYearGridRowBase_unstable, useCalendarYearGridRow_unstable } from './useCalendarYearGridRow'; +export { renderCalendarYearGridRow_unstable } from './renderCalendarYearGridRow'; +export { + calendarYearGridRowClassNames, + useCalendarYearGridRowStyles_unstable, +} from './useCalendarYearGridRowStyles.styles'; +export type { + CalendarYearGridRowProps, + CalendarYearGridRowSlots, + CalendarYearGridRowState, +} from './CalendarYearGridRow.types'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/renderCalendarYearGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/renderCalendarYearGridRow.tsx new file mode 100644 index 00000000000000..a23bac663d39d7 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/renderCalendarYearGridRow.tsx @@ -0,0 +1,20 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarYearGridRowSlots, CalendarYearGridRowState } from './CalendarYearGridRow.types'; + +/** + * Render the final JSX of CalendarYearGridRow. + */ +export const renderCalendarYearGridRow_unstable = (state: CalendarYearGridRowState): JSXElement => { + assertSlots(state); + + return state.motion ? ( + + + + ) : ( + + ); +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRow.tsx b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRow.tsx new file mode 100644 index 00000000000000..6781e111cdef3a --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRow.tsx @@ -0,0 +1,66 @@ +'use client'; + +import * as React from 'react'; +import { motionSlot } from '@fluentui/react-motion'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { useCalendarYearContext_unstable } from '../../contexts/calendarYearContext'; +import { CalendarYearGridCell } from '../CalendarYearGridCell'; +import type { CalendarYearGridRowProps, CalendarYearGridRowState } from './CalendarYearGridRow.types'; +import { DirectionalSlideIn } from '../../utils/calendarMotions'; +import { useAnimateBackwards } from '../../hooks/useAnimateBackwards'; + +/** + * Create the state required to render CalendarYearGridRow. + */ +export const useCalendarYearGridRowBase_unstable = ( + props: CalendarYearGridRowProps, + ref: React.Ref, +): CalendarYearGridRowState => { + const yearRows = useCalendarYearContext_unstable(ctx => ctx.yearRows); + + return { + cells: yearRows[props.rowIndex] ?? [], + components: { + root: 'div', + motion: React.Fragment, + }, + root: slot.always(getIntrinsicElementProps('div', { ref, role: 'row', ...props }, ['rowIndex']), { + elementType: 'div', + }), + }; +}; + +/** + * Create the state required to render CalendarYearGridRow. + */ +export const useCalendarYearGridRow_unstable = ( + props: CalendarYearGridRowProps, + ref: React.Ref, +): CalendarYearGridRowState => { + const state = useCalendarYearGridRowBase_unstable(props, ref); + const fromYear = useCalendarYearContext_unstable(ctx => ctx.fromYear); + const animateBackwards = useAnimateBackwards(fromYear); + + return { + ...state, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...state.components, + motion: DirectionalSlideIn, + }, + root: slot.always(getIntrinsicElementProps('div', { ref, role: 'row', ...props }, ['rowIndex']), { + defaultProps: { + children: state.cells.map(cell => ), + }, + elementType: 'div', + }), + motion: motionSlot(props.motion, { + defaultProps: { + animationDirection: 'vertical', + animateBackwards, + replayKey: fromYear, + }, + elementType: DirectionalSlideIn, + }), + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRowStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRowStyles.styles.ts new file mode 100644 index 00000000000000..6187bfab0e6427 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/CalendarYearGridRow/useCalendarYearGridRowStyles.styles.ts @@ -0,0 +1,26 @@ +'use client'; + +import { mergeClasses } from '@griffel/react'; +import { useCalendarItemStyles } from '../../hooks/useCalendarItemStyles.styles'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarYearGridRowSlots, CalendarYearGridRowState } from './CalendarYearGridRow.types'; + +/** + * Class names for calendarYearGridRow slots. + */ +export const calendarYearGridRowClassNames: SlotClassNames = { + root: 'fui-CalendarYearGridRow', + motion: 'fui-CalendarYearGridRow__motion', +}; + +/** + * Apply styling to the CalendarYearGridRow slots based on the state. + */ +export const useCalendarYearGridRowStyles_unstable = (state: CalendarYearGridRowState): CalendarYearGridRowState => { + const itemStyles = useCalendarItemStyles(); + + /* eslint-disable-next-line react-hooks/immutability */ + state.root.className = mergeClasses(calendarYearGridRowClassNames.root, itemStyles.buttonRow, state.root.className); + + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/contexts/calendarMonthContext.ts b/packages/react-components/react-calendar-preview/library/src/contexts/calendarMonthContext.ts new file mode 100644 index 00000000000000..00ca8116c5ca30 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/contexts/calendarMonthContext.ts @@ -0,0 +1,86 @@ +'use client'; + +import type * as React from 'react'; +import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import type { ContextSelector } from '@fluentui/react-context-selector'; + +/** + * One month cell in the grid. + */ +export type CalendarMonthCell = { + /** + * Index of the month within the year, matching `Date.prototype.getMonth()`. + */ + index: number; + /** + * Visible month name. + */ + label: string; + /** + * Accessible month name. + */ + ariaLabel: string; + /** + * Whether this is the displayed month. + */ + isNavigated: boolean; + /** + * Whether this is the current month. + */ + isCurrent: boolean; + /** + * Whether this is the selected month. + */ + isSelected: boolean; + /** + * Whether this month can be selected. + */ + isInBounds: boolean; + /** + * Handles selecting this month. + */ + onSelect: (ev: React.MouseEvent | React.KeyboardEvent) => void; +}; + +/** + * Resolved configuration and grid data shared with every row in the month grid. + */ +export type CalendarMonthContextValue = { + /** + * The year the grid is showing, used to replay the row motions on navigation. + */ + navigatedYear: number; + + /** + * Month cells grouped into rows of four. + */ + monthRows: CalendarMonthCell[][]; + + /** + * Reference to the displayed month button. + */ + navigatedMonthRef: React.RefObject; +}; + +/** + * Context values provided by CalendarMonth. + */ +export type CalendarMonthContextValues = { + /** + * Shared CalendarMonth configuration and grid data. + */ + calendarMonth: CalendarMonthContextValue; +}; + +const calendarMonthContext = createContext(undefined); + +export const CalendarMonthProvider = calendarMonthContext.Provider; + +export const useCalendarMonthContext_unstable = (selector: ContextSelector): T => + useContextSelector(calendarMonthContext, ctx => { + if (!ctx) { + throw new Error('CalendarMonth rows must be rendered inside a CalendarMonth.'); + } + + return selector(ctx); + }); diff --git a/packages/react-components/react-calendar-preview/library/src/contexts/calendarYearContext.ts b/packages/react-components/react-calendar-preview/library/src/contexts/calendarYearContext.ts new file mode 100644 index 00000000000000..6c83dea3fd7267 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/contexts/calendarYearContext.ts @@ -0,0 +1,95 @@ +'use client'; + +import type * as React from 'react'; +import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import type { ContextSelector } from '@fluentui/react-context-selector'; +import type { EventHandler } from '@fluentui/react-utilities'; +import type { CalendarYearSelectData } from '../CalendarYear'; + +/** + * One year cell in the grid. + */ +export type CalendarYearCell = { + /** + * Four-digit year represented by the cell. + */ + year: number; + /** + * Visible cell content. + */ + content: React.ReactNode; + /** + * Whether this is the current year. + */ + isCurrent: boolean; + /** + * Whether this is the selected year. + */ + isSelected: boolean; + /** + * Whether this is the focus target in the displayed range. + */ + isNavigated: boolean; + /** + * Whether this year cannot be selected. + */ + isDisabled: boolean; +}; + +/** + * Resolved configuration and grid data shared with every row in the year grid. + */ +export type CalendarYearContextValue = { + /** + * The first year displayed in the grid. + */ + fromYear: number; + + /** + * Reference to the current year button. + */ + currentYearRef: React.RefObject; + + /** + * Handles selecting a year. + */ + onSelectYear?: EventHandler; + + /** + * Reference to the selected year button. + */ + selectedYearRef: React.RefObject; + + /** + * Reference to the navigated year button. + */ + navigatedYearRef: React.RefObject; + + /** + * Year cells grouped into rows of four. + */ + yearRows: CalendarYearCell[][]; +}; + +/** + * Context values provided by CalendarYear. + */ +export type CalendarYearContextValues = { + /** + * Shared CalendarYear configuration and grid data. + */ + calendarYear: CalendarYearContextValue; +}; + +const calendarYearContext = createContext(undefined); + +export const CalendarYearProvider = calendarYearContext.Provider; + +export const useCalendarYearContext_unstable = (selector: ContextSelector): T => + useContextSelector(calendarYearContext, ctx => { + if (!ctx) { + throw new Error('CalendarYear rows must be rendered inside a CalendarYear.'); + } + + return selector(ctx); + }); diff --git a/packages/react-components/react-calendar-preview/library/src/hooks/useCalendarPickerStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/hooks/useCalendarPickerStyles.styles.ts new file mode 100644 index 00000000000000..dbf12df746a435 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/hooks/useCalendarPickerStyles.styles.ts @@ -0,0 +1,91 @@ +'use client'; + +import { tokens } from '@fluentui/react-theme'; +import { makeStyles } from '@griffel/react'; + +/** + * Chrome shared by the month and year pickers, which render the same header, navigation and grid + * shell. Each picker owns its slot class names and applies these styles itself. + */ +export const useCalendarPickerStyles = makeStyles({ + root: { + boxSizing: 'content-box', + overflow: 'hidden', + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`, + width: '196px', + }, + normalize: { + boxShadow: 'none', + boxSizing: 'border-box', + margin: '0', + padding: '0', + }, + header: { + display: 'flex', + }, + heading: { + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + borderRadius: tokens.borderRadiusMedium, + color: 'inherit', + flexGrow: 1, + fontFamily: 'inherit', + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightSemibold, + overflow: 'visible', + padding: `0 ${tokens.spacingHorizontalXS} 0 ${tokens.spacingHorizontalMNudge}`, + textAlign: 'left', + }, + hasHeaderClickCallback: { + // If this is updated, make sure to update headerIsClickable in useCalendarDayStyles as well + '&:hover': { + backgroundColor: tokens.colorBrandBackgroundInvertedHover, + color: tokens.colorBrandForegroundOnLightHover, + cursor: 'pointer', + outline: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + }, + '&:hover:active': { + backgroundColor: tokens.colorBrandBackgroundInvertedPressed, + color: tokens.colorBrandForegroundOnLightPressed, + cursor: 'pointer', + outline: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + }, + }, + navigation: { + alignItems: 'center', + display: 'flex', + }, + navigationButton: { + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorNeutralForeground1, + display: 'block', + fontFamily: 'inherit', + fontSize: tokens.fontSizeBase200, + height: '28px', + lineHeight: '28px', + minHeight: '28px', + minWidth: '28px', + overflow: 'visible', + padding: '0', + position: 'relative', + textAlign: 'center', + width: '28px', + + '&:hover': { + backgroundColor: tokens.colorBrandBackgroundInvertedHover, + color: tokens.colorBrandForegroundOnLightHover, + cursor: 'pointer', + outline: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + }, + + '&:hover:active': { + backgroundColor: tokens.colorBrandBackgroundInvertedPressed, + color: tokens.colorBrandForegroundOnLightPressed, + }, + }, + grid: { + marginTop: tokens.spacingVerticalXS, + }, +});