diff --git a/apps/public-docsite-v9/package.json b/apps/public-docsite-v9/package.json index 4c03e080a0a32..0b1ab57624f6c 100644 --- a/apps/public-docsite-v9/package.json +++ b/apps/public-docsite-v9/package.json @@ -20,6 +20,7 @@ "dependencies": { "@fluentui/react": "*", "@fluentui/react-calendar-compat": "*", + "@fluentui/react-calendar-preview": "*", "@fluentui/react-components": "*", "@fluentui/react-datepicker-compat": "*", "@fluentui/react-icons-compat": "*", diff --git a/packages/react-components/react-calendar-preview/library/bundle-size/Calendar.fixture.js b/packages/react-components/react-calendar-preview/library/bundle-size/Calendar.fixture.js new file mode 100644 index 0000000000000..42443161d0e55 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/bundle-size/Calendar.fixture.js @@ -0,0 +1,7 @@ +import { Calendar } from '@fluentui/react-calendar-preview'; + +console.log(Calendar); + +export default { + name: 'Calendar', +}; diff --git a/packages/react-components/react-calendar-preview/library/cypress.config.ts b/packages/react-components/react-calendar-preview/library/cypress.config.ts new file mode 100644 index 0000000000000..ca52cf041bbf2 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/cypress.config.ts @@ -0,0 +1,3 @@ +import { baseConfig } from '@fluentui/scripts-cypress'; + +export default baseConfig; diff --git a/packages/react-components/react-calendar-preview/library/docs/Spec.md b/packages/react-components/react-calendar-preview/library/docs/Spec.md index fb85f79f7fe59..9317b551040af 100644 --- a/packages/react-components/react-calendar-preview/library/docs/Spec.md +++ b/packages/react-components/react-calendar-preview/library/docs/Spec.md @@ -42,7 +42,7 @@ export const Example = () => { }; ``` -When `value` is omitted, Calendar initializes its internal selected date from `today`. Supplying `value` makes selection controlled. `onSelectDate` reports both the activated date and the range produced by `dateRangeType`. +When `value` is omitted, Calendar initializes its internal selected date from `defaultValue`, or from `today` when no default is provided. Use `null` for an explicitly empty selection. Supplying `value` makes selection controlled for the component's lifetime; do not switch between `undefined` and a date. `onSelectDate` reports both the activated date and the range produced by `dateRangeType`. ### Week selection with boundaries @@ -58,52 +58,55 @@ When `value` is omitted, Calendar initializes its internal selected date from `t /> ``` -### Month-only selection +### Start in the month view ```tsx - + ``` -### Localization +In an overlay layout, selecting a month returns to the day view for date selection. + +### Select a month without a day picker ```tsx -import { - Calendar, - createCalendarDateTimeFormatter, - createCalendarLabelFormatter, -} from '@fluentui/react-calendar-preview'; + +``` + +Selecting a month commits an available date and the configured range. If the navigated day is unavailable in that month, the first available date is used. A month with no available dates is disabled. Year navigation does not commit a selection. Use `monthPicker={null}` for a day-only calendar. -const formatDateTime = createCalendarDateTimeFormatter('fr-FR'); -const formatLabel = createCalendarLabelFormatter({ - weekNumber: data => `Semaine ${data.weekNumber}`, -}); +### Localization + +```tsx +import { Calendar, createCalendarDateTimeFormatter } from '@fluentui/react-calendar-preview'; `Semaine ${data.weekNumber}`, + }} goToTodayButton={{ children: "Aujourd'hui" }} showWeekNumbers />; ``` +This example overrides only part of the label set. For a fully localized control, provide every `CalendarFormatters` label, `goToTodayButton.children`, and the close button's label when shown. The Localized Formatting story demonstrates a complete German configuration. + +`createCalendarDateTimeFormatter(locales, { timeZone })` creates reusable Intl formatters. Locale extensions can affect labels and numbering, but date arithmetic and grid layout remain Gregorian and use local `Date` fields. A formatting time zone does not change the selected date or calendar arithmetic; keep application dates and label formatting consistent. + ## Variants ### Picker layout -| Configuration | Result | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `isDayPickerVisible` and `isMonthPickerVisible` | Day and month pickers are shown side by side with a divider. | -| `isDayPickerVisible` only | Only the day picker is shown. | -| `isMonthPickerVisible` only | Only the month picker is shown; choosing a month commits that month as the value. | -| `showMonthPickerAsOverlay` | The day and month pickers replace each other. Their headings switch views and restore focus in the newly shown view. | +| Configuration | Result | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| `layout="sideBySide"` | Day and month pickers are shown side by side with a divider. | +| `layout="overlay"` | The day and month pickers replace each other. Their headings switch views and restore focus in the newly shown view. | +| `layout="auto"` or unspecified | Uses the side-by-side layout above 440px and the overlay layout at narrower widths. | +| `view` / `defaultView` | Controls or initializes the active picker in the overlay layout. | +| `dayPicker={null}` | Shows the month/year picker and commits month selections. | +| `monthPicker={null}` | Shows only the day picker, without a view-toggle heading. | -Overlay mode is also used on initial render when the day picker is enabled and the target window is no wider than 440 CSS pixels. Picker visibility is initialized from the visibility props; those props do not control subsequent view toggles. +The automatic layout responds to viewport-width changes. Overlay view switching applies only when both picker slots are enabled. A single-picker calendar stays in that picker regardless of `view` or viewport width. ### Selection range @@ -122,6 +125,8 @@ The reported range is clipped to `minDate` and `maxDate` and excludes `restricte - `dayPicker.weeksToShow` fixes the number of visible week rows; otherwise the picker renders the number required by the navigated month. - `dayPicker.lightenDaysOutsideNavigatedMonth` visually deemphasizes dates outside the navigated month. - `dayPicker.getMarkedDays` marks dates without selecting or disabling them. +- `dayPicker.getDayCellProps(date)` customizes each visible day cell's native props, ref, and `button`, `dayLabel`, and `marker` slots. Click and keyboard handlers run before the built-in action; `preventDefault()` cancels that action. Transition rows do not invoke the customization callback. +- `monthPicker.yearPicker.renderYear(year)` customizes year content while retaining the year cell's selection, navigation, and disabled behavior. - `highlightCurrentMonth` and `highlightSelectedMonth` add current and selected treatments to month cells. - `goToTodayButton={null}` removes the default Go to today action. @@ -131,41 +136,44 @@ The source of truth is [Calendar.types.ts](../src/components/Calendar/Calendar.t ### Props -| Prop | Type | Default | Purpose | -| -------------------------- | ------------------------------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `value` | `Date` | `today` in uncontrolled use | Selected date. Supplying it controls selection. | -| `today` | `Date` | Client date and time | Reference date used for initialization, current-date styling, and Go to today. | -| `onSelectDate` | `EventHandler` | - | Called with the activated date and bounded, unrestricted selected range. | -| `onDismiss` | `EventHandler` | - | Called when Escape is pressed within Calendar or a configured close action is invoked. | -| `dateRangeType` | `'day' \| 'week' \| 'month' \| 'workWeek'` | `'day'` | Determines the selected range. | -| `firstDayOfWeek` | `DayOfWeek` | `'sunday'` | Controls week layout and week-range calculation. | -| `firstWeekOfYear` | `FirstWeekOfYear` | `'firstDay'` | Controls week-number calculation. | -| `workWeekDays` | `DayOfWeek[]` | Monday-Friday | Selectable days for a work-week range. | -| `isDayPickerVisible` | `boolean` | `true` | Initial day-picker visibility. | -| `isMonthPickerVisible` | `boolean` | `true` | Initial month-picker visibility. | -| `showMonthPickerAsOverlay` | `boolean` | `false` | Replaces one picker with the other instead of laying them out together. | -| `showWeekNumbers` | `boolean` | `false` | Adds week-number row headers. | -| `minDate` / `maxDate` | `Date` | - | Bound navigation and selection. | -| `restrictedDates` | `Date[]` | - | Prevent selection of individual dates. | -| `highlightCurrentMonth` | `boolean` | `false` | Marks today's month in the month grid. | -| `highlightSelectedMonth` | `boolean` | `false` | Marks the navigated/selected month in the month grid. | -| `allFocusable` | `boolean` | `false` | Includes unavailable dates and navigation actions in keyboard focus order while preserving disabled semantics. | -| `formatDateTime` | `FormatDateTime` | English formatter | Formats visible and accessible date values. | -| `formatLabel` | `FormatCalendarLabel` | English formatter | Formats complete accessible labels and announcements. | +| Prop | Type | Default | Purpose | +| ------------------------ | ------------------------------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `value` | `Date \| null` | `today` in uncontrolled use | Selected date; `null` is an empty controlled selection. | +| `defaultValue` | `Date \| null` | `today` | Initial uncontrolled selection; `null` starts empty. | +| `displayedDate` | `Date` | Selected date or `today` | Controls navigation independently of selection. | +| `defaultDisplayedDate` | `Date` | Selected date or `today` | Initial uncontrolled navigation date. | +| `onDisplayedDateChange` | `EventHandler` | - | Reports one navigation request per user action, including selection. | +| `onViewChange` | `EventHandler` | - | Reports a request to change the active overlay picker. | +| `today` | `Date` | Client date and time | Reference date used for initialization, current-date styling, and Go to today. | +| `onSelectDate` | `EventHandler` | - | Called with the activated date and bounded, unrestricted selected range. | +| `onDismiss` | `EventHandler` | - | Called when Escape is pressed within Calendar or a configured close action is invoked. | +| `dateRangeType` | `'day' \| 'week' \| 'month' \| 'workWeek'` | `'day'` | Determines the selected range. | +| `firstDayOfWeek` | `DayOfWeek` | `'sunday'` | Controls week layout and week-range calculation. | +| `firstWeekOfYear` | `FirstWeekOfYear` | `'firstDay'` | Controls week-number calculation. | +| `workWeekDays` | `DayOfWeek[]` | Monday-Friday | Selectable days for a work-week range. | +| `layout` | `'auto' \| 'sideBySide' \| 'overlay'` | `'auto'` | Controls whether pickers appear together or replace one another. | +| `view` / `defaultView` | `'day' \| 'month'` | `'day'` | Controls or initializes the active picker in the overlay layout. | +| `showWeekNumbers` | `boolean` | `false` | Adds week-number row headers. | +| `minDate` / `maxDate` | `Date` | - | Bound navigation and selection. | +| `restrictedDates` | `Date[]` | - | Prevent selection of individual dates. | +| `highlightCurrentMonth` | `boolean` | `false` | Marks today's month in the month grid. | +| `highlightSelectedMonth` | `boolean` | `false` | Marks the navigated/selected month in the month grid. | +| `allFocusable` | `boolean` | `false` | Includes unavailable dates and navigation actions in keyboard focus order while preserving disabled semantics. | +| `formatters` | `Partial` | English formatters | Overrides date and accessible-label formatters; omitted properties use defaults. | Calendar also accepts the native props of its root `div` and slot props for customizing the elements below. ### Slots -| Slot | Default | Purpose | -| -------------------- | --------------- | ------------------------------------------------------------ | -| `root` | `div` | Calendar container and owner of top-level keyboard handling. | -| `liveRegion` | `div` | Polite, atomic selected-date announcement. | -| `dayPicker` | `CalendarDay` | Day grid and month navigation. | -| `divider` | `div` | Separator rendered when both pickers are visible. | -| `monthPickerWrapper` | `div` | Groups the month picker and Go to today action. | -| `monthPicker` | `CalendarMonth` | Month grid and nested year picker. | -| `goToTodayButton` | `Button` | Navigates to today; set to `null` to omit. | +| Slot | Default | Purpose | +| -------------------- | --------------- | ------------------------------------------------------------------- | +| `root` | `div` | Calendar container and owner of top-level keyboard handling. | +| `liveRegion` | `div` | Polite, atomic selected-date announcement. | +| `dayPicker` | `CalendarDay` | Day grid and month navigation; `null` enables month-only selection. | +| `divider` | `div` | Separator rendered when both pickers are visible. | +| `monthPickerWrapper` | `div` | Groups the month picker and Go to today action. | +| `monthPicker` | `CalendarMonth` | Month grid and nested year picker; `null` enables day-only mode. | +| `goToTodayButton` | `button` | Navigates to today; set to `null` to omit. | The `dayPicker` and `monthPicker` slots expose their component APIs. Consumers can use these to configure `weeksToShow`, marked days, picker labels, navigation buttons, or year-picker visibility without Calendar duplicating every child prop. @@ -184,9 +192,59 @@ The `dayPicker` and `monthPicker` slots expose their component APIs. Consumers c Consumers normally render only `` and configure its structure through slot props. The picker slots resolve to `CalendarDay` and `CalendarMonth` by default. +### Standalone pickers + +`CalendarProvider` and `calendarContextDefaultValue` are public. The provider supplies shared configuration; it does not own state or render a calendar. `useCalendarContext_unstable` reads this configuration in custom compositions. + +```tsx +import * as React from 'react'; +import { useAnimationFrame } from '@fluentui/react-components'; +import { CalendarDay, CalendarProvider, calendarContextDefaultValue } from '@fluentui/react-calendar-preview'; +import type { CalendarDayHandle } from '@fluentui/react-calendar-preview'; + +export const StandaloneDay = () => { + const [requestAnimationFrame] = useAnimationFrame(); + const [value, setValue] = React.useState(null); + const [displayedDate, setDisplayedDate] = React.useState(() => new Date()); + const dayPickerRef = React.useRef(null); + const focusDayOnUpdate = React.useRef(false); + + React.useEffect(() => { + if (focusDayOnUpdate.current) { + requestAnimationFrame(() => dayPickerRef.current?.focus()); + focusDayOnUpdate.current = false; + } + }); + + return ( + setValue(data.date), + firstDayOfWeek: 'monday', + }} + > + { + focusDayOnUpdate.current = data.focusOnNavigatedDay; + setDisplayedDate(data.date); + }} + /> + + ); +}; +``` + +Independent day selection uses the provider's `setValue`; month and year pickers expose `onSelectDate` and `onSelectYear`. Day/month `navigatedDate` is consumer-controlled through `onNavigateDate`. Shared `minDate`/`maxDate`, `restrictedDates`, range settings, `today`, highlighting, and formatting apply through the provider. When overriding provider formatters, spread `calendarFormatters` to retain any defaults you need. + +Honor a day picker's `focusOnNavigatedDay` request through its focus handle after the navigated date has rendered. Schedule the focus for the next animation frame so the animated rows have mounted. Header navigation does not request a focus move. For a year picker, `navigatedYear` takes precedence over `selectedYear` for the displayed range and focus; selection remains independently highlighted. + ### Internal composition -Calendar owns selected, navigated-day, navigated-month, and picker-visibility state. It publishes shared date, range, formatting, and boundary configuration through `CalendarContext`. `CalendarDay`, `CalendarMonth`, and `CalendarYear` publish narrower contexts to their row and cell components so navigation and styling do not require cloning props through the tree. +Calendar owns selected-date, displayed-date, and picker-view state. The day and month pickers share the displayed date. It publishes shared date, range, formatting, and boundary configuration through `CalendarContext`. `CalendarDay`, `CalendarMonth`, and `CalendarYear` publish narrower contexts to their row and cell components so navigation and styling do not require cloning props through the tree. The styled components use Tabster arrow-navigation groups. Base state hooks are exported for a headless layer and intentionally leave picker resolution or roving-focus behavior to the caller. @@ -239,28 +297,32 @@ Day rows use semantic `tr`, weekday and week-number headers use `th`, and dates Calendar Preview preserves the main date-selection concepts but is not a drop-in replacement. - Replace v8 styling props (`styles`, `theme`, class-name maps) with v9 slot props, `className`, and Griffel styling. -- Update `onSelectDate` to the v9 event/data callback shape: `(event, { date, selectedDateRangeArray })`. -- Replace string resources and date-format callbacks with `formatLabel` and `formatDateTime`. Prefer `createCalendarLabelFormatter` and `createCalendarDateTimeFormatter` for partial label overrides and locale-aware formatting. +- Update `onSelectDate` to the v9 event/data callback shape: `(event, { date, selectedDateRange })`. +- Replace string resources and date-format callbacks with named overrides in `formatters`. Use `createCalendarDateTimeFormatter` for locale-aware date values. - Configure child-only behavior through the `dayPicker` and `monthPicker` slots. +- Replace `isDayPickerVisible={false}` with `dayPicker={null}` and `isMonthPickerVisible={false}` with `monthPicker={null}`. Replace `showMonthPickerAsOverlay` with `layout="overlay"`; a controlled `view="month"` alone is not month-only selection mode. +- Replace `showSixWeeksByDefault` with `dayPicker={{ weeksToShow: 6 }}`, `showCloseButton` with `dayPicker={{ closeButton: {} }}`, and `showGoToToday={false}` with `goToTodayButton={null}`. +- Replace `customDayCellRef` with `dayPicker.getDayCellProps`, returning a cell `ref` or declarative props. Replace `onRenderYear` with `monthPicker.yearPicker.renderYear`; customize year headings through the `heading` slot. +- For independent pickers, move shared props into a `CalendarProvider` value based on `calendarContextDefaultValue`. - Replace `componentRef`/`ICalendar` usage with normal React refs and application-owned focus or popup behavior. Calendar's root ref is an `HTMLDivElement`; child picker handles expose their own focus methods. - Re-test custom keyboard handling. Preventing default in a consumer `onKeyDown` intentionally suppresses Calendar's root key behavior. - Do not migrate production code to this preview package. Continue using the compatibility component until the v9 API is stable. ### From Fluent UI React Northstar (v0) -There is no compatibility layer. Map selected dates and callbacks to `value` and `onSelectDate`, replace shorthand customization with v9 slots, and provide localization through the Calendar formatter functions. Revalidate range selection, focus management, and popup composition as a new integration. +There is no compatibility layer. Map selected dates and callbacks to `value` and `onSelectDate`, replace shorthand customization with v9 slots, and provide localization through the Calendar `formatters` object. Revalidate range selection, focus management, and popup composition as a new integration. ## Behaviors ### State -- **Selected date:** controlled by `value` when supplied; otherwise initialized to `today` and updated on selection. -- **Navigated day:** the date represented by the active day cell. It can differ from the selected date while browsing. -- **Navigated month:** the active month in the month picker. It can differ from both the selected date and navigated day. -- **Controlled updates:** when a supplied `value` changes to a different date, both navigated states synchronize to it. -- **Picker visibility:** initialized from visibility and overlay props, then owned internally while switching overlay views. +- **Selected date:** controlled by `value` when supplied; otherwise initialized from `defaultValue` or `today`. Uncontrolled initial values are clamped to `minDate` and `maxDate`. An out-of-bounds controlled value is rendered as an empty selection. `null` clears selection without moving navigation. +- **Displayed date:** shared by the day and month pickers. Controlled by `displayedDate` or initialized from `defaultDisplayedDate`, the selected date, or `today`; displayed dates are clamped to `minDate` and `maxDate`. +- **Controlled updates:** changing `value` to a different date navigates to it only when `displayedDate` is uncontrolled. Prop changes do not fire interaction callbacks. A controlled `displayedDate` always takes precedence. +- **Navigation events:** each user selection or navigation action reports at most one `onDisplayedDateChange` request. Selection additionally reports `onSelectDate`; browsing alone does not select a date. +- **Picker view:** controlled by `view` when supplied; otherwise initialized from `defaultView` and owned internally while switching overlay views. - **Year picker visibility:** owned by `CalendarMonth`; selecting a year returns to the month grid. -- **Unavailable dates:** dates outside `minDate`/`maxDate` and dates in `restrictedDates` cannot be selected. Arrow navigation searches for the next available date. +- **Unavailable dates:** dates outside `minDate`/`maxDate` and dates in `restrictedDates` cannot be selected. Month navigation is bounded but is not blocked by a restricted anchor date. Disabled dates remain unavailable even when `allFocusable` enables focus. - **Go to today:** enabled only when a picker is navigated away from today's month/year. It navigates and focuses today but does not commit a selection. ### Pointer and touch @@ -272,13 +334,13 @@ Clicking or tapping an available day commits the configured range and moves navi | Focus area | Key | Behavior | | ------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Calendar | `Escape` | Stops propagation and calls `onDismiss`, when provided. | -| Calendar | `PageUp` | Navigates the day picker forward one month. | -| Calendar | `PageDown` | Navigates the day picker backward one month. | -| Calendar | `Ctrl+PageUp` | Navigates the day picker forward one year. | -| Calendar | `Ctrl+PageDown` | Navigates the day picker backward one year. | +| Calendar | `PageUp` | Navigates the day picker backward one month. | +| Calendar | `PageDown` | Navigates the day picker forward one month. | +| Calendar | `Shift+PageUp` | Navigates the day picker backward one year. | +| Calendar | `Shift+PageDown` | Navigates the day picker forward one year. | | Calendar | `Enter`, `Backspace` | Prevents the root browser default; focused descendants retain their own activation behavior. | | Day grid | Arrow keys | Moves by one day horizontally and one week vertically. Horizontal direction follows text direction. Crossing the rendered grid navigates to the adjacent month and restores focus. Unavailable dates are skipped. | -| Day gridcell | `Enter` | Selects the focused date and reports the configured range. | +| Day gridcell | `Enter` or `Space` | Selects the focused date and reports the configured range. | | Month/year grid | Arrow keys | Moves through the two-dimensional grid using roving focus. | | Month/year gridcell | `Enter` | Selects the focused month or year. | | Native button | `Enter` or `Space` | Activates navigation, heading, close, and Go to today actions using native button behavior. | @@ -306,7 +368,7 @@ Day, month, and year rows use directional motion when navigating between time ra The day grid's accessible name includes the navigated month and year, selected date, and today's date. Month and year grids are named by their displayed year or year range. Picker headings use polite, atomic live regions so navigation announces the displayed time range. Calendar's `liveRegion` politely and atomically announces a committed selection. -The default strings are English. Localized applications must provide both `formatDateTime` and `formatLabel`; customizing only visible date formatting leaves surrounding instructions and state labels in English. A custom `formatLabel` must return meaningful text for every label or delegate unhandled labels to the default formatter. +The default formatters produce English strings and US date formatting. Calendar merges partial `formatters` overrides with those defaults. Localized applications must provide `dateTime` and every label formatter; customizing only visible date formatting leaves surrounding instructions and state labels in English. Marked dates include the marked state in their accessible label. Visual range hover and pressed states are supplemental and do not reveal otherwise unavailable information. @@ -314,9 +376,12 @@ Marked dates include the marked state in their accessible label. Visual range ho - Each grid uses roving focus so arrow navigation does not add every cell to the tab sequence. - Switching between overlaid day and month pickers moves focus to the navigated cell in the destination picker. +- When responsive `auto` layout hides the picker that contains focus, focus moves to the navigated cell in the remaining picker. - Opening the year picker moves focus to its navigated year; selecting a year returns focus to the corresponding month. +- Calendar's month/year paging shortcuts restore focus to the navigated cell in the visible picker. - Go to today moves focus to today's day cell but does not select it. - Calendar never traps focus and does not automatically return focus to an external trigger on dismissal. +- `dayPicker.ref`, `monthPicker.ref`, and the nested `yearPicker.ref` expose focus handles and are merged with internal refs. Attaching consumer refs does not disable built-in focus restoration. ### Forced colors and zoom diff --git a/packages/react-components/react-calendar-preview/library/etc/react-calendar-preview.api.md b/packages/react-components/react-calendar-preview/library/etc/react-calendar-preview.api.md index 8d07245ab624a..82955bc06d96b 100644 --- a/packages/react-components/react-calendar-preview/library/etc/react-calendar-preview.api.md +++ b/packages/react-components/react-calendar-preview/library/etc/react-calendar-preview.api.md @@ -4,13 +4,50 @@ ```ts +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; import type { ContextSelector } from '@fluentui/react-context-selector'; +import type { DistributiveOmit } from '@fluentui/react-utilities'; import type { EventData } from '@fluentui/react-utilities'; -import type { EventHandler } from '@fluentui/react-utilities'; +import { EventHandler } from '@fluentui/react-utilities'; +import type { FadeParams } from '@fluentui/react-motion-components-preview'; import { FC } from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { MotionSlotProps } from '@fluentui/react-motion'; import { Provider } from 'react'; import { ProviderProps } from 'react'; -import type * as React_2 from 'react'; +import * as React_2 from 'react'; +import type { RefAttributes } from '@fluentui/react-utilities'; +import type { Slot } from '@fluentui/react-utilities'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +// @public +export function addDays(date: Date, days: number): Date; + +// @public +export function addMonths(date: Date, months: number): Date; + +// @public +export function addWeeks(date: Date, weeks: number): Date; + +// @public +export function addYears(date: Date, years: number): Date; + +// @public +export type AnimationDirection = 'horizontal' | 'vertical'; + +// @public +export const Calendar: ForwardRefComponent; + +// @public +export type CalendarBaseProps = CalendarProps; + +// @public +export type CalendarBaseState = CalendarState; + +// @public +export const calendarClassNames: SlotClassNames; // @public export const calendarContextDefaultValue: CalendarContextValue; @@ -39,12 +76,810 @@ export type CalendarContextValues = { calendar: CalendarContextValue; }; +// @public +export type CalendarDateLabelData = { + date: Date; + formattedDate: string; +}; + +// @public +export type CalendarDateTimeFormat = 'day' | 'month' | 'shortMonth' | 'year' | 'monthDayYear' | 'dayMonthYear' | 'monthYear' | 'weekday' | 'shortWeekday'; + +// @public +export type CalendarDateTimeFormatterOptions = Pick; + +// @public +export const CalendarDay: React_2.ForwardRefExoticComponent, "root"> & Omit<{ + as?: "div" | undefined; +} & Omit, HTMLDivElement>, "children"> & { + children?: any; +}, "ref"> & Pick, "daysToSelectInDayView" | "markedDays"> & { + navigatedDate?: Date; + onNavigateDate?: EventHandler; + onDismiss?: EventHandler; + onHeaderSelect?: EventHandler; + weeksToShow?: number; + lightenDaysOutsideNavigatedMonth?: boolean; + getMarkedDays?: (startingDate: Date, endingDate: Date) => Date[]; + getDayCellProps?: (date: Date) => CalendarDayCellProps; +} & React_2.RefAttributes>; + +// @public +export type CalendarDayBaseProps = CalendarDayProps; + +// @public +export type CalendarDayBaseState = CalendarDayState; + +// @public +export type CalendarDayCellProps = ComponentProps> & RefAttributes; + +// @public +export const calendarDayClassNames: SlotClassNames; + +// @public +export type CalendarDayContextValue = { + activeDescendantId: string; + calculateRoundedCorners: (above: boolean, below: boolean, left: boolean, right: boolean) => DayCorners; + daysToSelectInDayView?: number; + focusTargetDate?: Date; + getDayCellProps?: CalendarDayProps['getDayCellProps']; + getDayInfosInRangeOfDay: (dayToCompare: DayInfo) => DayInfo[]; + getRefsFromDayInfos: (dayInfosInRange: DayInfo[]) => (HTMLElement | null)[]; + lightenDaysOutsideNavigatedMonth: boolean; + navigatedDate: Date; + navigatedDayRef: React_2.RefObject; + onNavigateDate: EventHandler; + weekCorners: WeekCorners; + weeks: DayInfo[][]; + weeksToShow?: number; +}; + +// @public +export type CalendarDayContextValues = { + calendarDay: CalendarDayContextValue; +}; + +// @public +export type CalendarDayDismissData = EventData<'click' | 'keydown', React_2.SyntheticEvent>; + +// @public +export const CalendarDayGridCell: ForwardRefComponent; + +// @public +export const calendarDayGridCellClassNames: SlotClassNames; + +// @public +export type CalendarDayGridCellProps = ComponentProps> & { + day: DayInfo; + dayIndex: number; + weekIndex: number; + ariaHidden?: boolean; +}; + +// @public +export type CalendarDayGridCellSlots = { + root: NonNullable>; + button: NonNullable>; + dayLabel: NonNullable>; + marker?: Slot<'div'>; +}; + +// @public +export type CalendarDayGridCellState = ComponentState & { + day: DayInfo; + lightenDaysOutsideNavigatedMonth: boolean; +}; + +// @public +export const CalendarDayGridHeaderCell: ForwardRefComponent; + +// @public +export type CalendarDayGridHeaderCellBaseProps = ComponentProps> & Pick; + +// @public +export const calendarDayGridHeaderCellClassNames: SlotClassNames; + +// @public +export type CalendarDayGridHeaderCellProps = ComponentProps> & { + dayLabel: CalendarWeekDayLabel; +}; + +// @public +export type CalendarDayGridHeaderCellSlots = { + root: NonNullable>; + labelMotion?: Slot>; +}; + +// @public +export type CalendarDayGridHeaderCellState = ComponentState; + +// @public +export const CalendarDayGridHeaderRow: ForwardRefComponent; + +// @public +export const calendarDayGridHeaderRowClassNames: SlotClassNames; + +// @public +export type CalendarDayGridHeaderRowProps = ComponentProps>; + +// @public +export type CalendarDayGridHeaderRowSlots = { + root: NonNullable>; + weekNumberSpacerCell?: Slot<'th'>; +}; + +// @public +export type CalendarDayGridHeaderRowState = ComponentState & { + dayLabels: CalendarWeekDayLabel[]; +}; + +// @public +export const CalendarDayGridRow: ForwardRefComponent; + +// @public +export type CalendarDayGridRowBaseProps = Omit; + +// @public +export const calendarDayGridRowClassNames: SlotClassNames; + +// @public +export type CalendarDayGridRowProps = ComponentProps> & { + week: DayInfo[]; + weekIndex: number; + transition?: 'first' | 'last'; +}; + +// @public +export type CalendarDayGridRowSlots = { + root: NonNullable>; + weekNumberCell?: Slot<'th'>; + motion?: Slot>; +}; + +// @public +export type CalendarDayGridRowState = ComponentState & { + transition: CalendarDayGridRowProps['transition']; +}; + +// @public +export type CalendarDayHandle = { + focus(): void; +}; + +// @public +export type CalendarDayHeaderSelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent>; + +// @public +export type CalendarDayNavigateData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + date: Date; + focusOnNavigatedDay: boolean; +}; + +// @public +export type CalendarDayProps = ComponentProps> & Pick, 'daysToSelectInDayView' | 'markedDays'> & { + navigatedDate?: Date; + onNavigateDate?: EventHandler; + onDismiss?: EventHandler; + onHeaderSelect?: EventHandler; + weeksToShow?: number; + lightenDaysOutsideNavigatedMonth?: boolean; + getMarkedDays?: (startingDate: Date, endingDate: Date) => Date[]; + getDayCellProps?: (date: Date) => CalendarDayCellProps; +}; + +// @public +export type CalendarDaySelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + date: Date; + selectedDateRange: Date[]; +}; + +// @public +export type CalendarDaySlots = { + root: NonNullable>; + header: NonNullable>; + heading: NonNullable>; + navigation: NonNullable>; + previousMonthButton: NonNullable>; + nextMonthButton: NonNullable>; + closeButton?: Slot<'button'>; + grid: NonNullable>; + body: NonNullable>; +}; + +// @public +export type CalendarDayState = ComponentState & CalendarDayContextValue & { + monthAndYear: string; + prevMonthInBounds: boolean; + nextMonthInBounds: boolean; + headerIsClickable: boolean; +}; + +// @public +export type CalendarDismissData = EventData<'click' | 'keydown', React_2.SyntheticEvent>; + +// @public +export type CalendarFormatters = { + dateTime: (data: { + date: Date; + format: CalendarDateTimeFormat; + }) => string; + previousMonthLabel: (data: CalendarDateLabelData) => string; + nextMonthLabel: (data: CalendarDateLabelData) => string; + previousYearLabel: (data: CalendarDateLabelData) => string; + nextYearLabel: (data: CalendarDateLabelData) => string; + previousYearRangeLabel: (data: CalendarYearRangeLabelData) => string; + nextYearRangeLabel: (data: CalendarYearRangeLabelData) => string; + monthPickerHeaderLabel: (data: CalendarDateLabelData) => string; + yearPickerHeaderLabel: (data: CalendarDateLabelData) => string; + yearRangePickerHeaderLabel: (data: CalendarYearRangeLabelData) => string; + weekNumberLabel: (data: { + weekNumber: number; + }) => string; + selectedDateLabel: (data: CalendarDateLabelData) => string; + todayDateLabel: (data: CalendarDateLabelData) => string; + dayMarkedLabel: (data: CalendarDateLabelData) => string; +}; + +// @public +export const calendarFormatters: CalendarFormatters; + +// @public +export type CalendarLayout = 'auto' | 'sideBySide' | 'overlay'; + +// @public +export const CalendarMonth: React_2.ForwardRefExoticComponent, "root"> & Omit<{ + as?: "div" | undefined; +} & Omit, HTMLDivElement>, "children"> & { + children?: any; +}, "ref"> & { + navigatedDate?: Date; + selectedDate?: Date | null; + onSelectDate?: EventHandler; + onNavigateDate?: EventHandler; + onHeaderSelect?: EventHandler; + yearPickerHidden?: boolean; +} & React_2.RefAttributes>; + +// @public +export type CalendarMonthBaseProps = CalendarMonthProps; + +// @public +export type CalendarMonthBaseState = CalendarMonthState; + +// @public +export type CalendarMonthCell = { + index: number; + label: string; + ariaLabel: string; + isNavigated: boolean; + isCurrent: boolean; + isSelected: boolean; + isInBounds: boolean; + onSelect: (ev: React_2.MouseEvent | React_2.KeyboardEvent) => void; +}; + +// @public +export const calendarMonthClassNames: SlotClassNames>; + +// @public +export type CalendarMonthContextValue = { + navigatedYear: number; + monthRows: CalendarMonthCell[][]; + navigatedMonthRef: React_2.RefObject; +}; + +// @public +export type CalendarMonthContextValues = { + calendarMonth: CalendarMonthContextValue; +}; + +// @public +export const CalendarMonthGridCell: ForwardRefComponent; + +// @public +export const calendarMonthGridCellClassNames: SlotClassNames; + +// @public +export type CalendarMonthGridCellProps = ComponentProps> & { + month: CalendarMonthCell; +}; + +// @public +export type CalendarMonthGridCellSlots = { + root: NonNullable>; +}; + +// @public +export type CalendarMonthGridCellState = ComponentState & { + isCurrent: boolean; + isInBounds: boolean; + isSelected: boolean; +}; + +// @public +export const CalendarMonthGridRow: ForwardRefComponent; + +// @public +export const calendarMonthGridRowClassNames: SlotClassNames; + +// @public +export type CalendarMonthGridRowProps = ComponentProps> & { + rowIndex: number; +}; + +// @public +export type CalendarMonthGridRowSlots = { + root: NonNullable>; + motion?: Slot>; +}; + +// @public +export type CalendarMonthGridRowState = ComponentState & { + cells: CalendarMonthCell[]; +}; + +// @public +export type CalendarMonthHandle = { + focus(): void; +}; + +// @public +export type CalendarMonthHeaderSelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent>; + +// @public +export type CalendarMonthNavigateData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + date: Date; + focusOnNavigatedDay: boolean; +}; + +// @public +export type CalendarMonthProps = ComponentProps> & { + navigatedDate?: Date; + selectedDate?: Date | null; + onSelectDate?: EventHandler; + onNavigateDate?: EventHandler; + onHeaderSelect?: EventHandler; + yearPickerHidden?: boolean; +}; + +// @public +export type CalendarMonthSelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + date: Date; + selectedDateRange: Date[]; +}; + +// @public +export type CalendarMonthSlots = { + root: NonNullable>; + header: NonNullable>; + heading: NonNullable>; + navigation: NonNullable>; + previousYearButton: NonNullable>; + nextYearButton: NonNullable>; + grid: NonNullable>; + yearPicker: NonNullable>; +}; + +// @public +export type CalendarMonthState = ComponentState & { + isYearPickerVisible: boolean; + yearPickerRef: React_2.RefObject; + headerIsClickable: boolean; + isPrevYearInBounds: boolean; + isNextYearInBounds: boolean; + monthRows: CalendarMonthCell[][]; + navigatedMonthRef: React_2.RefObject; + navigatedYear: number; + yearString: string; +}; + +// @public +export type CalendarNavigateData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + displayedDate: Date; +}; + +// @public +export type CalendarProps = DistributiveOmit>, 'defaultValue'> & { + onSelectDate?: EventHandler; + onDismiss?: EventHandler; + value?: Date | null; + defaultValue?: Date | null; + displayedDate?: Date; + defaultDisplayedDate?: Date; + onDisplayedDateChange?: EventHandler; + view?: CalendarView; + defaultView?: CalendarView; + onViewChange?: EventHandler; + layout?: CalendarLayout; + today?: Date; + dateRangeType?: DateRangeType; + firstDayOfWeek?: DayOfWeek; + firstWeekOfYear?: FirstWeekOfYear; + showWeekNumbers?: boolean; + formatters?: Partial; + minDate?: Date; + maxDate?: Date; + restrictedDates?: Date[]; + workWeekDays?: DayOfWeek[]; + highlightCurrentMonth?: boolean; + highlightSelectedMonth?: boolean; + allFocusable?: boolean; +}; + // @public export const CalendarProvider: Provider & FC>; +// @public +export type CalendarSelectDateData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + date: Date; + selectedDateRange: Date[]; +}; + +// @public +export type CalendarSlots = { + root: NonNullable>; + liveRegion: NonNullable>; + dayPicker?: Slot; + divider: NonNullable>; + monthPickerWrapper: NonNullable>; + monthPicker?: Slot; + goToTodayButton?: Slot<'button'>; +}; + +// @public +export type CalendarState = ComponentState & CalendarContextValue & { + dayPickerRef: React_2.RefObject; + monthPickerRef: React_2.RefObject; + isDayPickerVisible: boolean; + isMonthPickerVisible: boolean; + isOverlay: boolean; +}; + +// @public +export type CalendarView = 'day' | 'month'; + +// @public +export type CalendarViewChangeData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + view: CalendarView; +}; + +// @public +export type CalendarWeekDayLabel = { + content: string; + label: string; + key: string; +}; + +// @public +export const CalendarYear: React_2.ForwardRefExoticComponent, "root"> & Omit<{ + as?: "div" | undefined; +} & Omit, HTMLDivElement>, "children"> & { + children?: any; +}, "ref"> & { + navigatedYear?: number; + selectedYear?: number; + renderYear?: (year: number) => React_2.ReactNode; + onSelectYear?: EventHandler; + onNavigateDate?: EventHandler; + onHeaderSelect?: EventHandler; +} & React_2.RefAttributes>; + +// @public +export type CalendarYearBaseProps = CalendarYearProps; + +// @public +export type CalendarYearBaseState = CalendarYearState; + +// @public +export type CalendarYearCell = { + year: number; + content: React_2.ReactNode; + isCurrent: boolean; + isSelected: boolean; + isNavigated: boolean; + isDisabled: boolean; +}; + +// @public +export const calendarYearClassNames: SlotClassNames; + +// @public +export type CalendarYearContextValue = { + fromYear: number; + currentYearRef: React_2.RefObject; + onSelectYear?: EventHandler; + selectedYearRef: React_2.RefObject; + navigatedYearRef: React_2.RefObject; + yearRows: CalendarYearCell[][]; +}; + +// @public +export type CalendarYearContextValues = { + calendarYear: CalendarYearContextValue; +}; + +// @public +export const CalendarYearGridCell: ForwardRefComponent; + +// @public +export const calendarYearGridCellClassNames: SlotClassNames; + +// @public +export type CalendarYearGridCellProps = ComponentProps> & { + cell: CalendarYearCell; +}; + +// @public +export type CalendarYearGridCellSlots = { + root: NonNullable>; +}; + +// @public +export type CalendarYearGridCellState = ComponentState & { + isCurrent: boolean; + isDisabled: boolean; + isSelected: boolean; +}; + +// @public +export const CalendarYearGridRow: ForwardRefComponent; + +// @public +export const calendarYearGridRowClassNames: SlotClassNames; + +// @public +export type CalendarYearGridRowProps = ComponentProps> & { + rowIndex: number; +}; + +// @public +export type CalendarYearGridRowSlots = { + root: NonNullable>; + motion?: Slot>; +}; + +// @public +export type CalendarYearGridRowState = ComponentState & { + cells: CalendarYearCell[]; +}; + +// @public +export type CalendarYearHandle = { + focus(): void; +}; + +// @public +export type CalendarYearHeaderSelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + focus: boolean; +}; + +// @public +export type CalendarYearNavigateData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + year: number; +}; + +// @public +export type CalendarYearProps = ComponentProps> & { + navigatedYear?: number; + selectedYear?: number; + renderYear?: (year: number) => React_2.ReactNode; + onSelectYear?: EventHandler; + onNavigateDate?: EventHandler; + onHeaderSelect?: EventHandler; +}; + +// @public +export interface CalendarYearRange { + // (undocumented) + fromYear: number; + // (undocumented) + toYear: number; +} + +// @public +export type CalendarYearRangeLabelData = { + fromYear: number; + toYear: number; + formattedRange: string; +}; + +// @public +export type CalendarYearSelectData = EventData<'click' | 'keydown', React_2.SyntheticEvent> & { + year: number; +}; + +// @public +export type CalendarYearSlots = { + root: NonNullable>; + header: NonNullable>; + heading: NonNullable>; + navigation: NonNullable>; + previousRangeButton: NonNullable>; + nextRangeButton: NonNullable>; + grid: NonNullable>; +}; + +// @public +export type CalendarYearState = ComponentState & { + fromYear: number; + hasHeaderClickCallback: boolean; + onSelectYear: CalendarYearProps['onSelectYear']; + prevDisabled: boolean; + nextDisabled: boolean; + yearRows: CalendarYearCell[][]; + currentYearRef: React_2.RefObject; + selectedYearRef: React_2.RefObject; + navigatedYearRef: React_2.RefObject; +}; + +// @public +export function createCalendarDateTimeFormatter(locales?: string | string[], options?: CalendarDateTimeFormatterOptions): CalendarFormatters['dateTime']; + +// @public +export type DateRangeType = 'day' | 'week' | 'month' | 'workWeek'; + +// @public +export type DayOfWeek = 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday'; + +// @public +export type FirstWeekOfYear = 'firstDay' | 'firstFullWeek' | 'firstFourDayWeek'; + +// @public +export function getDateRange(date: Date, dateRangeType: DateRangeType, firstDayOfWeek: DayOfWeek, workWeekDays?: DayOfWeek[], daysToSelectInDayView?: number): Date[]; + +// @public +export type MonthOfYear = 'january' | 'february' | 'march' | 'april' | 'may' | 'june' | 'july' | 'august' | 'september' | 'october' | 'november' | 'december'; + +// @public +export const renderCalendar_unstable: (state: CalendarBaseState, contextValues: CalendarContextValues) => JSXElement; + +// @public +export const renderCalendarDay_unstable: (state: CalendarDayBaseState, contextValues: CalendarDayContextValues) => JSXElement; + +// @public +export const renderCalendarDayGridCell_unstable: (state: CalendarDayGridCellState) => JSXElement; + +// @public +export const renderCalendarDayGridHeaderCell_unstable: (state: CalendarDayGridHeaderCellState) => JSXElement; + +// @public +export const renderCalendarDayGridHeaderRow_unstable: (state: CalendarDayGridHeaderRowState) => JSXElement; + +// @public +export const renderCalendarDayGridRow_unstable: (state: CalendarDayGridRowState) => JSXElement | null; + +// @public +export const renderCalendarMonth_unstable: (state: CalendarMonthBaseState, contextValues: CalendarMonthContextValues) => JSXElement; + +// @public +export const renderCalendarMonthGridCell_unstable: (state: CalendarMonthGridCellState) => JSXElement; + +// @public +export const renderCalendarMonthGridRow_unstable: (state: CalendarMonthGridRowState) => JSXElement; + +// @public +export const renderCalendarYear_unstable: (state: CalendarYearBaseState, contextValues: CalendarYearContextValues) => JSXElement; + +// @public +export const renderCalendarYearGridCell_unstable: (state: CalendarYearGridCellState) => JSXElement; + +// @public +export const renderCalendarYearGridRow_unstable: (state: CalendarYearGridRowState) => JSXElement; + +// @public +export const useCalendar_unstable: (props: CalendarProps, ref: React_2.Ref) => CalendarState; + +// @public +export const useCalendarBase_unstable: (props: CalendarBaseProps, ref: React_2.Ref) => CalendarBaseState; + // @public export const useCalendarContext_unstable: (selector: ContextSelector) => T; +// @public +export function useCalendarContextValues_unstable(state: CalendarBaseState): CalendarContextValues; + +// @public +export const useCalendarDay_unstable: (props: CalendarDayProps, ref: React_2.Ref) => CalendarDayState; + +// @public +export const useCalendarDayBase_unstable: (props: CalendarDayBaseProps, ref: React_2.Ref) => CalendarDayBaseState; + +// @public +export function useCalendarDayContextValues_unstable(state: CalendarDayBaseState): CalendarDayContextValues; + +// @public +export const useCalendarDayGridCell_unstable: (props: CalendarDayGridCellProps, ref: React_2.Ref) => CalendarDayGridCellState; + +// @public +export const useCalendarDayGridCellStyles_unstable: (state: CalendarDayGridCellState) => CalendarDayGridCellState; + +// @public +export const useCalendarDayGridHeaderCell_unstable: (props: CalendarDayGridHeaderCellProps, ref: React_2.Ref) => CalendarDayGridHeaderCellState; + +// @public +export const useCalendarDayGridHeaderCellBase_unstable: (props: CalendarDayGridHeaderCellBaseProps, ref: React_2.Ref) => CalendarDayGridHeaderCellBaseState; + +// @public +export const useCalendarDayGridHeaderCellStyles_unstable: (state: CalendarDayGridHeaderCellState) => CalendarDayGridHeaderCellState; + +// @public +export const useCalendarDayGridHeaderRow_unstable: (props: CalendarDayGridHeaderRowProps, ref: React_2.Ref) => CalendarDayGridHeaderRowState; + +// @public +export const useCalendarDayGridHeaderRowBase_unstable: (props: CalendarDayGridHeaderRowProps, ref: React_2.Ref) => CalendarDayGridHeaderRowState; + +// @public +export const useCalendarDayGridHeaderRowStyles_unstable: (state: CalendarDayGridHeaderRowState) => CalendarDayGridHeaderRowState; + +// @public +export const useCalendarDayGridRow_unstable: (props: CalendarDayGridRowProps, ref: React_2.Ref) => CalendarDayGridRowState; + +// @public +export const useCalendarDayGridRowBase_unstable: (props: CalendarDayGridRowBaseProps, ref: React_2.Ref) => CalendarDayGridRowState; + +// @public +export const useCalendarDayGridRowStyles_unstable: (state: CalendarDayGridRowState) => CalendarDayGridRowState; + +// @public +export const useCalendarDayStyles_unstable: (state: CalendarDayState) => CalendarDayState; + +// @public +export const useCalendarMonth_unstable: (props: CalendarMonthProps, ref: React_2.Ref) => CalendarMonthState; + +// @public +export const useCalendarMonthBase_unstable: (props: CalendarMonthBaseProps, ref: React_2.Ref) => CalendarMonthBaseState; + +// @public +export function useCalendarMonthContextValues_unstable(state: CalendarMonthBaseState): CalendarMonthContextValues; + +// @public +export const useCalendarMonthGridCell_unstable: (props: CalendarMonthGridCellProps, ref?: React_2.Ref) => CalendarMonthGridCellState; + +// @public +export const useCalendarMonthGridCellStyles_unstable: (state: CalendarMonthGridCellState) => CalendarMonthGridCellState; + +// @public +export const useCalendarMonthGridRow_unstable: (props: CalendarMonthGridRowProps, ref: React_2.Ref) => CalendarMonthGridRowState; + +// @public +export const useCalendarMonthGridRowBase_unstable: (props: CalendarMonthGridRowProps, ref: React_2.Ref) => CalendarMonthGridRowBaseState; + +// @public +export const useCalendarMonthGridRowStyles_unstable: (state: CalendarMonthGridRowState) => CalendarMonthGridRowState; + +// @public +export const useCalendarMonthStyles_unstable: (state: CalendarMonthState) => CalendarMonthState; + +// @public +export const useCalendarStyles_unstable: (state: CalendarState) => CalendarState; + +// @public +export const useCalendarYear_unstable: (props: CalendarYearProps, ref: React_2.Ref) => CalendarYearState; + +// @public +export const useCalendarYearBase_unstable: (props: CalendarYearBaseProps, ref: React_2.Ref) => CalendarYearBaseState; + +// @public +export function useCalendarYearContextValues_unstable(state: CalendarYearBaseState): CalendarYearContextValues; + +// @public +export const useCalendarYearGridCell_unstable: (props: CalendarYearGridCellProps, ref: React_2.Ref) => CalendarYearGridCellState; + +// @public +export const useCalendarYearGridCellStyles_unstable: (state: CalendarYearGridCellState) => CalendarYearGridCellState; + +// @public +export const useCalendarYearGridRow_unstable: (props: CalendarYearGridRowProps, ref: React_2.Ref) => CalendarYearGridRowState; + +// @public +export const useCalendarYearGridRowBase_unstable: (props: CalendarYearGridRowProps, ref: React_2.Ref) => CalendarYearGridRowState; + +// @public +export const useCalendarYearGridRowStyles_unstable: (state: CalendarYearGridRowState) => CalendarYearGridRowState; + +// @public +export const useCalendarYearStyles_unstable: (state: CalendarYearState) => CalendarYearState; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-calendar-preview/library/package.json b/packages/react-components/react-calendar-preview/library/package.json index 7e46662cff192..248b859ef4d12 100644 --- a/packages/react-components/react-calendar-preview/library/package.json +++ b/packages/react-components/react-calendar-preview/library/package.json @@ -22,12 +22,11 @@ "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.9", - "@fluentui/react-button": "^9.11.0", "@fluentui/react-context-selector": "^9.2.19", "@fluentui/react-icons": "^2.0.245", "@fluentui/react-jsx-runtime": "^9.4.5", "@fluentui/react-motion": "^9.16.2", - "@fluentui/react-motion-components-preview": "^0.15.7", + "@fluentui/react-motion-components-preview": "^0.15.8", "@fluentui/react-shared-contexts": "^9.26.3", "@fluentui/react-tabster": "^9.26.17", "@fluentui/react-theme": "^9.2.2", diff --git a/packages/react-components/react-calendar-preview/library/src/Calendar.ts b/packages/react-components/react-calendar-preview/library/src/Calendar.ts new file mode 100644 index 0000000000000..c76de8644db43 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/Calendar.ts @@ -0,0 +1,24 @@ +export type { + CalendarBaseProps, + CalendarBaseState, + CalendarContextValue, + CalendarContextValues, + CalendarDismissData, + CalendarLayout, + CalendarNavigateData, + CalendarProps, + CalendarSelectDateData, + CalendarSlots, + CalendarState, + CalendarView, + CalendarViewChangeData, +} from './components/Calendar/index'; +export { + Calendar, + calendarClassNames, + renderCalendar_unstable, + useCalendarBase_unstable, + useCalendarContextValues_unstable, + useCalendar_unstable, + useCalendarStyles_unstable, +} from './components/Calendar/index'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx new file mode 100644 index 0000000000000..5ae742af17765 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.cy.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import { mount } from '@fluentui/scripts-cypress'; +import { FluentProvider } from '@fluentui/react-provider'; +import { webLightTheme } from '@fluentui/react-theme'; + +import { Calendar } from './Calendar'; +import { calendarClassNames } from './useCalendarStyles.styles'; +import { calendarDayClassNames } from '../CalendarDay/useCalendarDayStyles.styles'; +import { calendarMonthClassNames } from '../CalendarMonth/useCalendarMonthStyles.styles'; +import { calendarMonthGridCellClassNames } from '../CalendarMonthGridCell/useCalendarMonthGridCellStyles.styles'; +import { calendarYearGridCellClassNames } from '../CalendarYearGridCell/useCalendarYearGridCellStyles.styles'; +import type { JSXElement } from '@fluentui/react-utilities'; + +const mountFluent = (element: JSXElement) => { + mount({element}); +}; + +/** + * Friday, September 18 2020. September 2020 renders as Aug 30 - Oct 3 with a Sunday week start. + */ +const today = new Date(2020, 8, 18); + +/** + * Days are keyed off the accessible name of the inner button, which is unique across the grid. + * The `tr` filter excludes the two off-screen transition rows. + */ +const day = (label: string) => `tr:not([aria-hidden="true"]) td[role="gridcell"]:has(button[aria-label="${label}"])`; + +const heading = `.${calendarDayClassNames.heading}`; +const nextMonth = `.${calendarDayClassNames.nextMonthButton}`; +const goToToday = `.${calendarClassNames.goToTodayButton}`; + +describe('Calendar', () => { + describe('day grid keyboard navigation', () => { + it('moves focus between days with the arrow keys', () => { + mountFluent(); + + cy.get(day('September 18, 2020')).focus().trigger('keydown', { key: 'ArrowRight', bubbles: true }); + cy.focused().find('button').should('have.attr', 'aria-label', 'September 19, 2020'); + + cy.focused().trigger('keydown', { key: 'ArrowDown', bubbles: true }); + cy.focused().find('button').should('have.attr', 'aria-label', 'September 26, 2020'); + + cy.focused().trigger('keydown', { key: 'ArrowLeft', bubbles: true }); + cy.focused().find('button').should('have.attr', 'aria-label', 'September 25, 2020'); + }); + + it('navigates to the next month when arrowing past the last visible day', () => { + mountFluent(); + + cy.get(day('October 3, 2020')).focus().trigger('keydown', { key: 'ArrowRight', bubbles: true }); + + cy.get(heading).should('have.text', 'October 2020'); + cy.focused().find('button').should('have.attr', 'aria-label', 'October 4, 2020'); + }); + + it('navigates a month with PageDown and a year with Shift+PageDown', () => { + mountFluent(); + + cy.get(day('September 18, 2020')).focus().trigger('keydown', { key: 'PageDown', bubbles: true }); + cy.get(heading).should('have.text', 'October 2020'); + + cy.get(day('October 18, 2020')).focus().trigger('keydown', { key: 'PageDown', shiftKey: true, bubbles: true }); + cy.get(heading).should('have.text', 'October 2021'); + }); + + it('calls onDismiss on Escape', () => { + const onDismiss = cy.stub().as('onDismiss'); + mountFluent(); + + cy.get(day('September 18, 2020')).focus().realPress('Escape'); + + cy.get('@onDismiss').should('have.been.calledOnce'); + }); + }); + + describe('range hover and selection', () => { + it('highlights the whole week on hover when dateRangeType is week', () => { + mountFluent(); + + cy.get(day('September 16, 2020')).realHover(); + cy.get('td[data-range-hovered]').should('have.length', 7); + + cy.get(heading).realHover(); + cy.get('td[data-range-hovered]').should('not.exist'); + }); + + it('marks the range as pressed while the mouse is down', () => { + mountFluent(); + + cy.get(day('September 16, 2020')).trigger('mousedown'); + cy.get('td[data-range-pressed]').should('have.length', 7); + + cy.get(day('September 16, 2020')).trigger('mouseup'); + cy.get('td[data-range-pressed]').should('not.exist'); + }); + + it('highlights the configured number of days when daysToSelectInDayView is set', () => { + mountFluent(); + + cy.get(day('September 15, 2020')).realHover(); + cy.get('td[data-range-hovered]').should('have.length', 4); + }); + + it('selects the whole week on click when dateRangeType is week', () => { + const onSelectDate = cy.stub().as('onSelectDate'); + mountFluent(); + + cy.get(day('September 16, 2020')).click(); + + cy.get('td[data-selected]').should('have.length', 7); + cy.get('@onSelectDate').its('firstCall.args.1.selectedDateRange').should('have.length', 7); + }); + + it('resolves the same date range when selecting with Enter', () => { + const onSelectDate = cy.stub().as('onSelectDate'); + mountFluent(); + + cy.get(day('September 16, 2020')).focus().realPress('Enter'); + + cy.get('td[data-selected]').should('have.length', 7); + cy.get('@onSelectDate').its('firstCall.args.1.selectedDateRange').should('have.length', 7); + }); + + it('resolves the same date range when selecting with Space', () => { + const onSelectDate = cy.stub().as('onSelectDate'); + mountFluent(); + + cy.get(day('September 16, 2020')).focus().realPress('Space'); + + cy.get('td[data-selected]').should('have.length', 7); + cy.get('@onSelectDate').its('firstCall.args.1.selectedDateRange').should('have.length', 7); + }); + + it('does not select a restricted day', () => { + const onSelectDate = cy.stub().as('onSelectDate'); + mountFluent(); + + cy.get(day('September 16, 2020')).should('have.attr', 'data-outside-bounds'); + cy.get(day('September 16, 2020')).click({ force: true }); + + cy.get('@onSelectDate').should('not.have.been.called'); + }); + }); + + describe('go to today', () => { + it('navigates back to today and moves focus to it', () => { + mountFluent(); + + cy.get(nextMonth).click(); + cy.get(heading).should('have.text', 'October 2020'); + + cy.get(goToToday).should('be.enabled').click(); + + cy.get(heading).should('have.text', 'September 2020'); + cy.focused().find('button').should('have.attr', 'aria-label', 'September 18, 2020'); + }); + + it('is disabled while today is already in view', () => { + mountFluent(); + + cy.get(goToToday).should('be.disabled'); + }); + + it('is not rendered when the go-to-today slot is null', () => { + mountFluent(); + + cy.get(goToToday).should('not.exist'); + }); + }); + + describe('month and year pickers', () => { + it('keeps the month highlight on the navigated month', () => { + mountFluent(); + + cy.get(`.${calendarMonthGridCellClassNames.root}[data-selected]`).should('have.text', 'Sep'); + + cy.get(nextMonth).click(); + + cy.get(`.${calendarMonthGridCellClassNames.root}[data-selected]`).should('have.text', 'Oct'); + }); + + it('opens the year picker on the navigated year', () => { + const december = new Date(2020, 11, 18); + mountFluent(); + + cy.get(nextMonth).click(); + cy.get(`.${calendarMonthClassNames.heading}`).should('have.text', '2021').click(); + + cy.get(`.${calendarYearGridCellClassNames.root}[data-selected]`).should('have.text', '2021'); + }); + + it('navigates the day grid when a month is picked', () => { + mountFluent(); + + cy.get(`.${calendarMonthGridCellClassNames.root}`).contains('Dec').click(); + + cy.get(heading).should('have.text', 'December 2020'); + cy.focused().find('button').should('have.attr', 'aria-label', 'December 18, 2020'); + }); + + it('toggles between the pickers when the day header is clicked in overlay mode', () => { + mountFluent(); + + cy.get(`.${calendarDayClassNames.root}`).should('exist'); + cy.get(`.${calendarMonthClassNames.root}`).should('not.exist'); + + cy.get(heading).click(); + + cy.get(`.${calendarMonthClassNames.root}`).should('be.visible'); + cy.get(`.${calendarDayClassNames.root}`).should('not.exist'); + }); + }); + + describe('date boundaries', () => { + it('keeps focus on the next month button once it reaches maxDate', () => { + mountFluent(); + + cy.get(nextMonth).focus().realPress('Enter'); + + cy.get(heading).should('have.text', 'October 2020'); + cy.get(nextMonth).should('have.attr', 'aria-disabled', 'true').and('be.focused'); + }); + + it('does not navigate past maxDate', () => { + mountFluent(); + + cy.get(nextMonth) + .should('have.attr', 'aria-disabled', 'true') + .and('have.css', 'pointer-events', 'none') + .click({ force: true }); + + cy.get(heading).should('have.text', 'September 2020'); + }); + + it('marks days outside minDate and maxDate as out of bounds', () => { + mountFluent(); + + cy.get(day('September 9, 2020')).should('have.attr', 'data-outside-bounds'); + cy.get(day('September 10, 2020')).should('not.have.attr', 'data-outside-bounds'); + cy.get(day('September 19, 2020')).should('have.attr', 'data-outside-bounds'); + }); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.test.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.test.tsx new file mode 100644 index 0000000000000..36faa2b41eaba --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.test.tsx @@ -0,0 +1,487 @@ +import * as React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { Calendar } from './Calendar'; +import { isConformant } from '../../testing/isConformant'; +import { calendarFormatters } from '../../utils'; +import type { CalendarDayHandle } from '../CalendarDay/CalendarDay.types'; +import type { CalendarMonthHandle } from '../CalendarMonth/CalendarMonth.types'; + +expect.extend(toHaveNoViolations); + +describe('Calendar', () => { + isConformant({ + Component: Calendar, + displayName: 'Calendar', + }); + + it('should render without crashing when value is undefined', () => { + expect(() => render()).not.toThrow(); + }); + + it('should render correctly when value is undefined', () => { + const { container } = render(); + expect(container.querySelector('[role="grid"]')).not.toBeNull(); + }); + + it('supports an explicitly empty controlled selection', () => { + const { container } = render(); + + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(''); + expect(container.querySelector('td[aria-selected="true"]')).toBeNull(); + }); + + it('uses defaultValue only for an uncontrolled initial selection', () => { + const { container } = render( + , + ); + + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('Selected date September 18, 2020'); + }); + + it('clamps uncontrolled initial selection and navigation to date boundaries', () => { + const minDate = new Date(2020, 8, 10); + const { container } = render( + , + ); + + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('Selected date September 10, 2020'); + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('September 2020'), + ); + }); + + it('renders an out-of-bounds controlled selection as empty and clamps controlled navigation', () => { + const { container } = render( + , + ); + + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(''); + expect(container.querySelector('td[aria-selected="true"]')).toBeNull(); + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('September 2020'), + ); + }); + + it('controls the displayed date independently from the selected value', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('January 2021'); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('Selected date September 18, 2020'); + }); + + it('reports displayed-date navigation without mutating a controlled date', () => { + const onDisplayedDateChange = jest.fn(); + const displayedDate = new Date(2020, 8, 18); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstElementChild!, { key: 'PageUp' }); + + expect(onDisplayedDateChange.mock.calls[0][1].displayedDate).toEqual(new Date(2020, 7, 18)); + expect(container.textContent).toContain('September 2020'); + }); + + it('navigates to an externally updated value when displayedDate is uncontrolled', () => { + const onDisplayedDateChange = jest.fn(); + const { container, rerender } = render( + , + ); + + rerender(); + + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('January 2021, Selected date January 5, 2021'), + ); + expect(onDisplayedDateChange).not.toHaveBeenCalled(); + }); + + it('preserves a controlled displayedDate when value changes', () => { + const displayedDate = new Date(2020, 8, 18); + const { container, rerender } = render(); + + rerender(); + + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('September 2020, Selected date January 5, 2021'), + ); + }); + + it('allows navigation to a month whose anchor date is restricted', () => { + const { getByTitle, getByRole, container } = render( + , + ); + + fireEvent.click(getByTitle('Next month October')); + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('October 2020'), + ); + + fireEvent.click(getByRole('gridcell', { name: 'September' })); + fireEvent.click(getByRole('gridcell', { name: 'October' })); + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + expect.stringContaining('October 2020'), + ); + }); + + it('reports one navigation callback for one day selection', () => { + const onSelectDate = jest.fn(); + const onDisplayedDateChange = jest.fn(); + const { getByRole } = render( + , + ); + + fireEvent.click(getByRole('button', { name: 'September 15, 2020' })); + + expect(onSelectDate).toHaveBeenCalledTimes(1); + expect(onDisplayedDateChange).toHaveBeenCalledTimes(1); + expect(onDisplayedDateChange.mock.calls[0][1].displayedDate).toEqual(new Date(2020, 8, 15)); + }); + + it('supports an uncontrolled overlay view and reports view changes', () => { + const onViewChange = jest.fn(); + const { getByRole, container } = render( + , + ); + + expect(container.querySelector('table[role="grid"]')).toBeNull(); + fireEvent.click(getByRole('gridcell', { name: 'September' })); + + expect(onViewChange.mock.calls[0][1].view).toBe('day'); + expect(container.querySelector('table[role="grid"]')).not.toBeNull(); + }); + + it('updates the visible picker when a controlled overlay view changes', () => { + const { container, rerender } = render(); + expect(container.querySelector('table[role="grid"]')).not.toBeNull(); + + rerender(); + + expect(container.querySelector('table[role="grid"]')).toBeNull(); + expect(container.querySelector('[role="grid"]')).not.toBeNull(); + }); + + it('restores focus when responsive overlay mode hides the focused picker', async () => { + const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + let onMediaChange: (() => void) | undefined; + const mediaQuery = { + matches: false, + addEventListener: (_type: string, listener: () => void) => { + onMediaChange = listener; + }, + removeEventListener: jest.fn(), + }; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn(() => mediaQuery), + }); + + try { + const { getByRole, container } = render( + , + ); + + getByRole('gridcell', { name: 'September' }).focus(); + mediaQuery.matches = true; + act(() => onMediaChange?.()); + + await waitFor(() => expect(container.querySelector('table[role="grid"]')).not.toBeNull()); + await waitFor(() => expect(getByRole('button', { name: 'September 18, 2020' }).closest('td')).toHaveFocus()); + } finally { + if (matchMediaDescriptor) { + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor); + } else { + delete (window as Partial).matchMedia; + } + } + }); + + it('selects a bounded month without switching to the day picker', () => { + const onSelectDate = jest.fn(); + const onDisplayedDateChange = jest.fn(); + const onViewChange = jest.fn(); + const { getByRole, container } = render( + , + ); + + fireEvent.click(getByRole('gridcell', { name: 'October' })); + + expect(onSelectDate).toHaveBeenCalledTimes(1); + expect(onSelectDate.mock.calls[0][1].date).toEqual(new Date(2020, 9, 10)); + expect(onSelectDate.mock.calls[0][1].selectedDateRange).toHaveLength(21); + expect(onSelectDate.mock.calls[0][1].selectedDateRange).not.toContainEqual(new Date(2020, 9, 18)); + expect(onDisplayedDateChange).toHaveBeenCalledTimes(1); + expect(onViewChange).not.toHaveBeenCalled(); + expect(container.querySelector('table[role="grid"]')).toBeNull(); + }); + + it('renders a day-only calendar without a month-toggle action', () => { + const { queryByRole, container } = render(); + + expect(container.querySelector('table[role="grid"]')).not.toBeNull(); + expect(container.querySelector('.fui-CalendarMonth')).toBeNull(); + expect(queryByRole('button', { name: /change month/ })).toBeNull(); + }); + + it.each([2000, 2045])('opens the displayed year in month-only mode with a selection in %s', async selectedYear => { + const onSelectDate = jest.fn(); + const { getByRole } = render( + , + ); + + fireEvent.click(getByRole('button', { name: '2040, change year' })); + + expect(getByRole('grid')).toHaveAttribute('aria-label', '2040 - 2051'); + await waitFor(() => expect(getByRole('gridcell', { name: '2040' })).toHaveFocus()); + expect(onSelectDate).not.toHaveBeenCalled(); + }); + + it('preserves built-in focus restoration with consumer picker refs', async () => { + const dayRef = React.createRef(); + const monthRef = React.createRef(); + const { getByRole } = render( + , + ); + + expect(dayRef.current?.focus).toBeInstanceOf(Function); + expect(monthRef.current?.focus).toBeInstanceOf(Function); + fireEvent.click(getByRole('button', { name: 'Go to today' })); + + await waitFor(() => expect(getByRole('button', { name: 'September 18, 2020' }).closest('td')).toHaveFocus()); + }); + + it('provides shared configuration to the day picker through context', () => { + const { container } = render(); + + expect(container.querySelector('th[scope="row"]')).not.toBeNull(); + }); + + it('lets the dayPicker slot override what Calendar computes', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('January 2021'); + }); + + it('renders the go-to-today button by default', () => { + const { queryByRole } = render(); + + expect(queryByRole('button', { name: 'Go to today' })).not.toBeNull(); + }); + + it('does not render the go-to-today button when its slot is null', () => { + const { queryByRole } = render(); + + expect(queryByRole('button', { name: 'Go to today' })).toBeNull(); + }); + + it('calls a consumer onKeyDown and still dismisses on Escape', () => { + const onKeyDown = jest.fn(); + const onDismiss = jest.fn(); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstElementChild!, { key: 'Escape' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('moves back one month on PageUp keyboard navigation', () => { + const onDisplayedDateChange = jest.fn(); + const value = new Date(2020, 8, 18); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstElementChild!, { key: 'PageUp' }); + + expect(onDisplayedDateChange).toHaveBeenCalledTimes(1); + expect(onDisplayedDateChange.mock.calls[0][1].displayedDate).toEqual(new Date(2020, 7, 18)); + }); + + it('clamps PageDown navigation to the maximum date', () => { + const value = new Date(2020, 8, 18); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstElementChild!, { key: 'PageDown' }); + + expect(container.textContent).toContain('October 2020'); + expect(container.querySelector('button[aria-label="October 10, 2020"]')?.closest('td')).toHaveAttribute( + 'tabindex', + '0', + ); + }); + + it('leaves day-grid paging keys to Calendar instead of Tabster', () => { + const { container } = render(); + const attributes = JSON.parse(container.querySelector('table[role="grid"]')!.getAttribute('data-tabster')!); + + expect(attributes.focusable.ignoreKeydown).toEqual({ PageUp: true, PageDown: true }); + }); + + it.each([ + { key: 'PageUp', shiftKey: false, targetDate: new Date(2020, 7, 18) }, + { key: 'PageDown', shiftKey: false, targetDate: new Date(2020, 9, 18) }, + { key: 'PageUp', shiftKey: true, targetDate: new Date(2019, 8, 18) }, + { key: 'PageDown', shiftKey: true, targetDate: new Date(2021, 8, 18) }, + ])('restores day focus after $key with shiftKey=$shiftKey', async ({ key, shiftKey, targetDate }) => { + const onDisplayedDateChange = jest.fn(); + const onSelectDate = jest.fn(); + const { getByRole } = render( + , + ); + const day = getByRole('button', { name: 'September 18, 2020' }).closest('td')!; + day.focus(); + + fireEvent.keyDown(day, { key, shiftKey }); + + expect(onDisplayedDateChange).toHaveBeenCalledTimes(1); + expect(onDisplayedDateChange.mock.calls[0][1].displayedDate).toEqual(targetDate); + expect(onSelectDate).not.toHaveBeenCalled(); + const targetLabel = calendarFormatters.dateTime({ date: targetDate, format: 'dayMonthYear' }); + await waitFor(() => expect(getByRole('button', { name: targetLabel }).closest('td')).toHaveFocus()); + }); + + it('allows a consumer to cancel paging from a day cell', () => { + const onDisplayedDateChange = jest.fn(); + const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); + const { getByRole } = render( + , + ); + const day = getByRole('button', { name: 'September 18, 2020' }).closest('td')!; + day.focus(); + + fireEvent.keyDown(day, { key: 'PageUp' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onDisplayedDateChange).not.toHaveBeenCalled(); + expect(day).toHaveFocus(); + }); + + it('clamps go-to-today navigation to the allowed date range', () => { + const { getByRole, container } = render( + , + ); + + fireEvent.click(getByRole('button', { name: 'Go to today' })); + + expect(container.textContent).toContain('September 2020'); + expect(container.querySelector('button[aria-label="September 10, 2020"]')?.closest('td')).toHaveAttribute( + 'tabindex', + '0', + ); + }); + + it('moves the highlighted month with navigation rather than with the selected value', () => { + const { getByTitle, getByRole } = render( + , + ); + + expect(getByRole('gridcell', { name: 'September' })).toHaveAttribute('data-selected'); + + fireEvent.click(getByTitle('Next month October')); + + expect(getByRole('gridcell', { name: 'October' })).toHaveAttribute('data-selected'); + expect(getByRole('gridcell', { name: 'September' })).not.toHaveAttribute('data-selected'); + }); + + it('opens the year picker on the navigated year rather than the selected year', () => { + const { getByTitle, getByRole } = render( + , + ); + + fireEvent.click(getByTitle('Next month January')); + fireEvent.click(getByRole('button', { name: '2021, change year' })); + + expect(getByRole('gridcell', { name: '2021' })).toHaveAttribute('data-selected'); + }); + + it('uses localized strings for the selected date, today, and go-to-today button', () => { + type FormatDateTime = typeof calendarFormatters.dateTime; + const dateTime: FormatDateTime = ({ date, format }) => `Localized ${calendarFormatters.dateTime({ date, format })}`; + + const { getByRole, container } = render( + `Chosen: ${data.formattedDate}`, + todayDateLabel: data => `Current: ${data.formattedDate}`, + }} + goToTodayButton={{ children: 'Jump to current date' }} + />, + ); + + expect(getByRole('button', { name: 'Jump to current date' })).toBeTruthy(); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('Chosen: Localized September 18, 2020'); + expect(container.querySelector('table[role="grid"]')).toHaveAttribute( + 'aria-label', + 'Localized September 2020, Chosen: Localized September 18, 2020, Current: Localized October 20, 2020', + ); + }); + + it('passes accessibility checks', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.tsx new file mode 100644 index 0000000000000..026f4f4bd76b6 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; +import { useCalendar_unstable } from './useCalendar'; +import { useCalendarContextValues_unstable } from './useCalendarContextValues'; +import { useCalendarStyles_unstable } from './useCalendarStyles.styles'; +import { renderCalendar_unstable } from './renderCalendar'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { CalendarProps } from './Calendar.types'; + +/** + * Calendar shows a date grid and lets the user pick a date, a week, a work week, or a month. + */ +export const Calendar: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCalendar_unstable(props, ref); + + const contextValues = useCalendarContextValues_unstable(state); + + useCalendarStyles_unstable(state); + + return renderCalendar_unstable(state, contextValues); +}); + +Calendar.displayName = 'Calendar'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.types.ts b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.types.ts new file mode 100644 index 0000000000000..2c10578338362 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/Calendar.types.ts @@ -0,0 +1,257 @@ +import type * as React from 'react'; +import type { + ComponentProps, + ComponentState, + DistributiveOmit, + EventData, + EventHandler, + Slot, +} from '@fluentui/react-utilities'; +import type { CalendarFormatters, DateRangeType, DayOfWeek, FirstWeekOfYear } from '../../utils'; +import type { CalendarContextValue, CalendarDayHandle } from '../CalendarDay/CalendarDay.types'; +import type { CalendarMonthHandle } from '../CalendarMonth/CalendarMonth.types'; +import type { CalendarDay } from '../../CalendarDay'; +import type { CalendarMonth } from '../../CalendarMonth'; + +export type { CalendarContextValue, CalendarContextValues } from '../../contexts/calendarContext'; + +/** + * Slots available to the Calendar component. + */ +export type CalendarSlots = { + /** + * Root container. + */ + root: NonNullable>; + + /** + * Announces the selected date to assistive technology. + */ + liveRegion: NonNullable>; + + /** + * The day picker. Set to `null` to select months without showing the day picker. + */ + dayPicker?: Slot; + + /** + * Separates the day picker from the month picker when both are visible. + */ + divider: NonNullable>; + + /** + * Wraps the month picker and the "go to today" button. + */ + monthPickerWrapper: NonNullable>; + + /** + * The month picker. Set to `null` to show only the day picker. + */ + monthPicker?: Slot; + + /** + * The "go to today" button. Set to `null` to hide it. + */ + goToTodayButton?: Slot<'button'>; +}; + +/** + * Event data for a date selection. + */ +export type CalendarSelectDateData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + /** + * The date selected by the user. + */ + date: Date; + /** + * Dates selected according to the configured range type. + */ + selectedDateRange: Date[]; +}; + +/** Event data reported when the displayed date changes through user interaction. */ +export type CalendarNavigateData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + /** The date displayed by the calendar. */ + displayedDate: Date; +}; + +/** Picker views available when Calendar uses an overlay layout. */ +export type CalendarView = 'day' | 'month'; + +/** Event data reported when user interaction changes the active overlay view. */ +export type CalendarViewChangeData = EventData<'click' | 'keydown', React.SyntheticEvent> & { + /** The active picker when the calendar uses an overlay layout. */ + view: CalendarView; +}; + +/** Controls how the day and month pickers are arranged. */ +export type CalendarLayout = 'auto' | 'sideBySide' | 'overlay'; + +/** + * Event data for dismissing the Calendar. + */ +export type CalendarDismissData = EventData<'click' | 'keydown', React.SyntheticEvent>; + +/** + * Props for the Calendar component. + */ +export type CalendarProps = DistributiveOmit>, 'defaultValue'> & { + /** + * Callback for when a date is selected + * @param date - The date the user selected + * @param selectedDateRange - The resultant list of dates that are selected based on the date range type set + * for the component. + */ + onSelectDate?: EventHandler; + + /** + * Callback for when calendar is closed + */ + onDismiss?: EventHandler; + + /** The selected date. `null` represents an explicitly empty controlled selection. */ + value?: Date | null; + + /** The initial selected date when uncontrolled. `null` starts with no selection. */ + defaultValue?: Date | null; + + /** The displayed date, controlled independently of value. When omitted, external value changes navigate automatically. */ + displayedDate?: Date; + + /** The initial displayed date when uncontrolled. */ + defaultDisplayedDate?: Date; + + /** Called once per user navigation or selection action. External prop changes do not fire this callback. */ + onDisplayedDateChange?: EventHandler; + + /** The active picker in an overlay layout. */ + view?: CalendarView; + + /** The initial active picker when uncontrolled. */ + defaultView?: CalendarView; + + /** Called when user interaction changes the active picker. */ + onViewChange?: EventHandler; + + /** + * How the day and month pickers are arranged. `auto` switches to an overlay below 440px. + * @default 'auto' + */ + layout?: CalendarLayout; + + /** + * Value of today. If unspecified, current time in client machine will be used. + */ + today?: Date; + + /** + * The date range type indicating how many days should be selected as the user + * selects days + * @default 'day' + */ + dateRangeType?: DateRangeType; + + /** + * The first day of the week for your locale. + * @default 'sunday' + */ + firstDayOfWeek?: DayOfWeek; + + /** + * Defines when the first week of the year should start. + * @default 'firstDay' + */ + firstWeekOfYear?: FirstWeekOfYear; + + /** + * Whether the calendar should show the week-of-year number before each week row. + * @default false + */ + showWeekNumbers?: boolean; + + /** + * Overrides date and accessibility label formatters. Omitted formatters use the defaults. + */ + formatters?: Partial; + + /** + * If set the Calendar will not allow navigation to or selection of a date earlier than this value. + */ + minDate?: Date; + + /** + * If set the Calendar will not allow navigation to or selection of a date later than this value. + */ + maxDate?: Date; + + /** + * If set the Calendar will not allow selection of dates in this array. + */ + restrictedDates?: Date[]; + + /** + * The days that are selectable when `dateRangeType` is `workWeek`. + * If `dateRangeType` is not `workWeek` this property does nothing. + * @default ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'] + */ + workWeekDays?: DayOfWeek[]; + + /** + * Whether the month picker should highlight the current month + * @default false + */ + highlightCurrentMonth?: boolean; + + /** + * Whether the month picker should highlight the selected month + * @default false + */ + highlightSelectedMonth?: boolean; + + /** + * Allows all dates and buttons to be focused, including disabled ones + * @default false + */ + allFocusable?: boolean; +}; + +/** + * State used to render the Calendar component. + */ +export type CalendarState = ComponentState & + CalendarContextValue & { + /** + * Reference to the day picker. + */ + dayPickerRef: React.RefObject; + + /** + * Reference to the month picker. + */ + monthPickerRef: React.RefObject; + + /** + * Whether the day picker is rendered. + */ + isDayPickerVisible: boolean; + + /** + * Whether the month picker is rendered. + */ + isMonthPickerVisible: boolean; + + /** + * Whether the day and month pickers replace one another. + */ + isOverlay: boolean; + }; + +/** + * Props used by the base Calendar state hook. + */ +export type CalendarBaseProps = CalendarProps; + +/** + * State returned by the base Calendar state hook. + */ +export type CalendarBaseState = CalendarState; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/index.ts b/packages/react-components/react-calendar-preview/library/src/components/Calendar/index.ts new file mode 100644 index 0000000000000..0d2a094764da5 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/index.ts @@ -0,0 +1,20 @@ +export { Calendar } from './Calendar'; +export { useCalendarBase_unstable, useCalendar_unstable } from './useCalendar'; +export { useCalendarContextValues_unstable } from './useCalendarContextValues'; +export { renderCalendar_unstable } from './renderCalendar'; +export type { + CalendarBaseProps, + CalendarBaseState, + CalendarContextValue, + CalendarContextValues, + CalendarDismissData, + CalendarLayout, + CalendarNavigateData, + CalendarProps, + CalendarSelectDateData, + CalendarSlots, + CalendarState, + CalendarView, + CalendarViewChangeData, +} from './Calendar.types'; +export { calendarClassNames, useCalendarStyles_unstable } from './useCalendarStyles.styles'; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/renderCalendar.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/renderCalendar.tsx new file mode 100644 index 0000000000000..e9afc51a23b4d --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/renderCalendar.tsx @@ -0,0 +1,31 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import { CalendarProvider } from '../../contexts/calendarContext'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { CalendarBaseState, CalendarContextValues, CalendarSlots } from './Calendar.types'; + +/** + * Render the final JSX of Calendar. + */ +export const renderCalendar_unstable = (state: CalendarBaseState, contextValues: CalendarContextValues): JSXElement => { + assertSlots(state); + + return ( + + + + {state.isDayPickerVisible && state.dayPicker && } + {state.isDayPickerVisible && state.isMonthPickerVisible && } + {state.isMonthPickerVisible ? ( + + {state.monthPicker && } + {state.goToTodayButton && } + + ) : ( + state.goToTodayButton && + )} + + + ); +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx new file mode 100644 index 0000000000000..12bfd27a8bc88 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendar.tsx @@ -0,0 +1,535 @@ +'use client'; + +import * as React from 'react'; +import { Backspace, Enter, Escape, PageDown, PageUp } from '@fluentui/keyboard-keys'; +import { + getIntrinsicElementProps, + slot, + useControllableState, + useEventCallback, + useMergedRefs, +} from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; + +import { + addMonths, + addYears, + compareDatePart, + calendarFormatters as defaultCalendarFormatters, + focusAsync, + isRestrictedDate, +} from '../../utils'; +import { CalendarDay } from '../CalendarDay/CalendarDay'; +import { CalendarMonth } from '../CalendarMonth/CalendarMonth'; +import type { DayOfWeek } from '../../utils'; +import type { + CalendarDayHandle, + CalendarDayDismissData, + CalendarDayNavigateData, + CalendarDaySelectData, +} from '../CalendarDay/CalendarDay.types'; +import type { CalendarMonthHandle, CalendarMonthNavigateData } from '../CalendarMonth/CalendarMonth.types'; +import type { CalendarBaseProps, CalendarBaseState, CalendarProps, CalendarState } from './Calendar.types'; + +const MIN_SIZE_FORCE_OVERLAY = 440; + +const defaultWorkWeekDays: DayOfWeek[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + +function useDateState({ + defaultDisplayedDate, + defaultValue, + displayedDate, + maxDate, + minDate, + onDisplayedDateChange, + onSelectDate, + restrictedDates, + today, + value, +}: Pick< + CalendarProps, + | 'defaultDisplayedDate' + | 'defaultValue' + | 'displayedDate' + | 'maxDate' + | 'minDate' + | 'onDisplayedDateChange' + | 'onSelectDate' + | 'restrictedDates' + | 'value' +> & { + today: Date; +}) { + const resolveDate = (date: Date): Date => { + let resolvedDate = date; + + if (minDate && compareDatePart(resolvedDate, minDate) < 0) { + resolvedDate = minDate; + } else if (maxDate && compareDatePart(resolvedDate, maxDate) > 0) { + resolvedDate = maxDate; + } + + return resolvedDate; + }; + + const [selectedDateState, setSelectedDate] = useControllableState({ + state: value, + defaultState: defaultValue && resolveDate(defaultValue), + initialState: resolveDate(today), + }); + const selectedDate = + selectedDateState && compareDatePart(selectedDateState, resolveDate(selectedDateState)) !== 0 + ? null + : selectedDateState; + + const initialDisplayedDate = resolveDate(defaultDisplayedDate ?? selectedDate ?? today); + const [navigatedDateState = initialDisplayedDate, setNavigatedDate] = useControllableState({ + state: displayedDate, + initialState: initialDisplayedDate, + }); + const navigatedDate = resolveDate(navigatedDateState); + + const valueTimestamp = value?.getTime(); + const [previousValueTimestamp, setPreviousValueTimestamp] = React.useState(valueTimestamp); + if (!Object.is(previousValueTimestamp, valueTimestamp)) { + setPreviousValueTimestamp(valueTimestamp); + if (value && displayedDate === undefined) { + setNavigatedDate(resolveDate(value)); + } + } + + const navigate = useEventCallback((date: Date, ev: React.SyntheticEvent | Event) => { + const resolvedDate = resolveDate(date); + if (resolvedDate) { + setNavigatedDate(resolvedDate); + onDisplayedDateChange?.(ev, { + event: ev as React.SyntheticEvent, + type: ev.type === 'keydown' ? 'keydown' : 'click', + displayedDate: resolvedDate, + }); + } + }); + + // Stable identity: this is published on the calendar context, which would otherwise change every render. + const onDateSelected = useEventCallback((ev: React.SyntheticEvent | Event, data: CalendarDaySelectData) => { + const { date } = data; + if (isRestrictedDate(date, { minDate, maxDate, restrictedDates })) { + return; + } + + const selectedDateRange = data.selectedDateRange.filter( + rangeDate => !isRestrictedDate(rangeDate, { minDate, maxDate, restrictedDates }), + ); + + setSelectedDate(date); + onSelectDate?.(ev, { ...data, date, selectedDateRange }); + }); + + return [selectedDate, navigatedDate, onDateSelected, navigate, resolveDate] as const; +} + +function useResponsiveOverlay(layout: CalendarProps['layout']) { + const { targetDocument } = useFluent(); + const win = targetDocument?.defaultView; + const [isNarrow, setIsNarrow] = React.useState(false); + + React.useEffect(() => { + if (!win?.matchMedia || layout === 'sideBySide' || layout === 'overlay') { + return; + } + + const mediaQuery = win.matchMedia(`(max-width: ${MIN_SIZE_FORCE_OVERLAY}px)`); + const onChange = () => setIsNarrow(mediaQuery.matches); + onChange(); + mediaQuery.addEventListener('change', onChange); + return () => mediaQuery.removeEventListener('change', onChange); + }, [layout, win]); + + return isNarrow; +} + +function useVisibilityState({ + dayPicker, + defaultView, + layout, + monthPicker, + onViewChange, + view: viewProp, +}: Pick) { + const responsiveOverlay = useResponsiveOverlay(layout); + const [view = 'day', setView] = useControllableState({ + state: viewProp, + defaultState: viewProp === undefined ? defaultView : undefined, + initialState: 'day' as const, + }); + const hasDayPicker = dayPicker !== null; + const hasMonthPicker = monthPicker !== null; + const isOverlay = + hasDayPicker && hasMonthPicker && (layout === 'overlay' || (layout !== 'sideBySide' && responsiveOverlay)); + const isDayPickerVisible = hasDayPicker && (!isOverlay || view === 'day'); + const isMonthPickerVisible = hasMonthPicker && (!isOverlay || view === 'month'); + + const toggleDayMonthPickerVisibility = (ev: React.SyntheticEvent | Event) => { + const nextView = view === 'day' ? 'month' : 'day'; + setView(nextView); + onViewChange?.(ev, { + event: ev as React.SyntheticEvent, + type: ev.type === 'keydown' ? 'keydown' : 'click', + view: nextView, + }); + }; + + return [isMonthPickerVisible, isDayPickerVisible, isOverlay, toggleDayMonthPickerVisibility] as const; +} + +/** + * Create the state required to render Calendar. + */ +export const useCalendarBase_unstable = ( + props: CalendarBaseProps, + ref: React.Ref, +): CalendarBaseState => { + const { + allFocusable = false, + dateRangeType = 'day', + defaultDisplayedDate, + defaultValue, + defaultView, + divider, + displayedDate, + firstDayOfWeek = 'sunday', + firstWeekOfYear = 'firstDay', + formatters: formatterOverrides, + goToTodayButton, + highlightCurrentMonth = false, + highlightSelectedMonth = false, + liveRegion, + layout, + maxDate, + minDate, + monthPickerWrapper, + onDismiss, + onDisplayedDateChange, + onSelectDate, + onViewChange, + restrictedDates, + showWeekNumbers = false, + today: todayProp, + value, + view, + workWeekDays = defaultWorkWeekDays, + } = props; + + const formatters = React.useMemo( + () => (formatterOverrides ? { ...defaultCalendarFormatters, ...formatterOverrides } : defaultCalendarFormatters), + [formatterOverrides], + ); + + const today = React.useMemo(() => todayProp ?? new Date(), [todayProp]); + + const [selectedDate, navigatedDate, onDateSelected, navigate, resolveDate] = useDateState({ + defaultDisplayedDate, + defaultValue, + displayedDate, + maxDate, + minDate, + onDisplayedDateChange, + onSelectDate, + restrictedDates, + value, + today, + }); + + const [isMonthPickerVisible, isDayPickerVisible, isOverlay, toggleDayMonthPickerVisibility] = useVisibilityState({ + dayPicker: props.dayPicker, + defaultView, + layout, + monthPicker: props.monthPicker, + onViewChange, + view, + }); + + const dayPickerRef = React.useRef(null); + const monthPickerRef = React.useRef(null); + const focusedPicker = React.useRef<'day' | 'month' | undefined>(undefined); + const focusOnUpdate = React.useRef(false); + const { targetDocument } = useFluent(); + const win = targetDocument?.defaultView; + + const focus = useEventCallback(() => { + if (isDayPickerVisible && dayPickerRef.current) { + focusAsync(dayPickerRef.current, win); + } else if (isMonthPickerVisible && monthPickerRef.current) { + focusAsync(monthPickerRef.current, win); + } + }); + + React.useEffect(() => { + if (focusOnUpdate.current) { + focus(); + focusOnUpdate.current = false; + } + }); + + React.useEffect(() => { + if ( + (focusedPicker.current === 'day' && !isDayPickerVisible) || + (focusedPicker.current === 'month' && !isMonthPickerVisible) + ) { + focus(); + } + }, [focus, isDayPickerVisible, isMonthPickerVisible]); + + const focusOnNextUpdate = () => { + focusOnUpdate.current = true; + }; + + const onNavigateDayDate = useEventCallback( + (ev: React.SyntheticEvent | Event, data: CalendarDayNavigateData): void => { + const { date, focusOnNavigatedDay } = data; + navigate(date, ev); + if (focusOnNavigatedDay) { + focusOnNextUpdate(); + } + }, + ); + + const onNavigateMonthDate = useEventCallback( + (ev: React.SyntheticEvent | Event, data: CalendarMonthNavigateData): void => { + const { date, focusOnNavigatedDay } = data; + if (focusOnNavigatedDay) { + focusOnNextUpdate(); + } + + navigate(date, ev); + }, + ); + + const onHeaderSelect = useEventCallback((ev: React.SyntheticEvent | Event, _data): void => { + toggleDayMonthPickerVisibility(ev); + focusOnNextUpdate(); + }); + + const onDayDismiss = useEventCallback((ev: React.SyntheticEvent | Event, data: CalendarDayDismissData): void => { + onDismiss?.(ev, data); + }); + + const onGotoToday = useEventCallback((ev: React.SyntheticEvent): void => { + const resolvedToday = resolveDate(today); + if (!resolvedToday) { + return; + } + + navigate(resolvedToday, ev); + if (isOverlay && isMonthPickerVisible) { + toggleDayMonthPickerVisibility(ev); + } + focusOnNextUpdate(); + }); + + const onRootKeyDown = useEventCallback((ev: React.KeyboardEvent): void => { + props.onKeyDown?.(ev); + + if (ev.isDefaultPrevented()) { + return; + } + + switch (ev.key) { + case Enter: + case Backspace: + ev.preventDefault(); + break; + + case Escape: + ev.stopPropagation(); + onDismiss?.(ev, { event: ev, type: 'keydown' }); + break; + + case PageUp: + navigate(ev.shiftKey ? addYears(navigatedDate, -1) : addMonths(navigatedDate, -1), ev); + focusOnNextUpdate(); + ev.preventDefault(); + break; + + case PageDown: + navigate(ev.shiftKey ? addYears(navigatedDate, 1) : addMonths(navigatedDate, 1), ev); + focusOnNextUpdate(); + ev.preventDefault(); + break; + + default: + break; + } + }); + + const onRootFocusCapture = useEventCallback((ev: React.FocusEvent): void => { + props.onFocusCapture?.(ev); + + const target = ev.target as HTMLElement; + if (target.closest('.fui-CalendarDay')) { + focusedPicker.current = 'day'; + } else if (target.closest('.fui-CalendarMonth')) { + focusedPicker.current = 'month'; + } + }); + + const formattedToday = formatters.dateTime({ date: today, format: 'monthDayYear' }); + const todayDateString = formatters.todayDateLabel({ date: today, formattedDate: formattedToday }); + const selectedDateString = selectedDate + ? formatters.selectedDateLabel({ + date: selectedDate, + formattedDate: formatters.dateTime({ date: selectedDate, format: 'monthDayYear' }), + }) + : ''; + const isMonthOnly = props.dayPicker === null; + + const resolvedToday = resolveDate(today); + const goToTodayEnabled = + !!resolvedToday && + (navigatedDate.getFullYear() !== resolvedToday.getFullYear() || + navigatedDate.getMonth() !== resolvedToday.getMonth()); + + return { + allFocusable, + dateRangeType, + firstDayOfWeek, + firstWeekOfYear, + formatters, + highlightCurrent: highlightCurrentMonth, + highlightSelected: highlightSelectedMonth, + maxDate, + minDate, + restrictedDates, + setValue: onDateSelected, + showWeekNumbers, + today, + value: selectedDate, + workWeekDays, + dayPickerRef, + isDayPickerVisible, + isMonthPickerVisible, + monthPickerRef, + isOverlay, + components: { + root: 'div', + liveRegion: 'div', + divider: 'div', + monthPickerWrapper: 'div', + goToTodayButton: 'button', + dayPicker: 'div', + monthPicker: 'div', + }, + root: slot.always( + getIntrinsicElementProps('div', { ref, ...props, onFocusCapture: onRootFocusCapture, onKeyDown: onRootKeyDown }, [ + 'defaultValue', + ]), + { + elementType: 'div', + }, + ), + liveRegion: slot.always(liveRegion, { + defaultProps: { + 'aria-atomic': true, + 'aria-live': 'polite', + children: selectedDateString, + }, + elementType: 'div', + }), + divider: slot.always(divider, { + elementType: 'div', + }), + monthPickerWrapper: slot.always(monthPickerWrapper, { + elementType: 'div', + }), + goToTodayButton: slot.optional(goToTodayButton, { + renderByDefault: true, + defaultProps: { + children: 'Go to today', + disabled: !goToTodayEnabled, + onClick: onGotoToday, + onKeyDown: (ev: React.KeyboardEvent) => { + if (ev.key === Enter) { + ev.preventDefault(); + onGotoToday(ev); + } + }, + type: 'button', + }, + elementType: 'button', + }), + dayPicker: slot.optional(props.dayPicker, { + renderByDefault: true, + defaultProps: { + grid: { + 'aria-label': `${formatters.dateTime({ + date: navigatedDate, + format: 'monthYear', + })}, ${selectedDateString}, ${todayDateString}`, + }, + navigatedDate, + onDismiss: onDismiss ? onDayDismiss : undefined, + onHeaderSelect: isOverlay ? onHeaderSelect : undefined, + onNavigateDate: onNavigateDayDate, + }, + elementType: 'div', + }), + monthPicker: slot.optional(props.monthPicker, { + renderByDefault: true, + defaultProps: { + navigatedDate, + selectedDate: isMonthOnly ? selectedDate : navigatedDate, + onSelectDate: isMonthOnly ? onDateSelected : undefined, + onHeaderSelect: isOverlay ? onHeaderSelect : undefined, + onNavigateDate: onNavigateMonthDate, + }, + elementType: 'div', + }), + }; +}; + +/** + * Create the state required to render Calendar. + * Resolves the day and month picker slots, which the base hook leaves to the caller so the + * headless layer can render its own picker components from the same computed props. + */ +export const useCalendar_unstable = (props: CalendarProps, ref: React.Ref): CalendarState => { + const { dayPicker, monthPicker, goToTodayButton } = props; + const state = useCalendarBase_unstable(props, ref); + const resolvedDayPicker = slot.optional(dayPicker, { + renderByDefault: true, + defaultProps: state.dayPicker, + elementType: CalendarDay, + }); + const resolvedMonthPicker = slot.optional(monthPicker, { + renderByDefault: true, + defaultProps: state.monthPicker, + elementType: CalendarMonth, + }); + const mergedDayPickerRef = useMergedRefs(state.dayPickerRef, resolvedDayPicker?.ref); + const mergedMonthPickerRef = useMergedRefs(state.monthPickerRef, resolvedMonthPicker?.ref); + + if (resolvedDayPicker) { + resolvedDayPicker.ref = mergedDayPickerRef; + } + if (resolvedMonthPicker) { + resolvedMonthPicker.ref = mergedMonthPickerRef; + } + + return { + ...state, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...state.components, + dayPicker: CalendarDay, + monthPicker: CalendarMonth, + goToTodayButton: 'button', + }, + goToTodayButton: slot.optional(goToTodayButton, { + renderByDefault: !!state.goToTodayButton, + defaultProps: state.goToTodayButton, + elementType: 'button', + }), + dayPicker: resolvedDayPicker, + monthPicker: resolvedMonthPicker, + }; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarContextValues.ts b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarContextValues.ts new file mode 100644 index 0000000000000..f489ab59c8662 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarContextValues.ts @@ -0,0 +1,66 @@ +'use client'; + +import * as React from 'react'; +import type { CalendarBaseState, CalendarContextValue, CalendarContextValues } from './Calendar.types'; + +/** + * Creates state for the CalendarContextValues component. + */ +export function useCalendarContextValues_unstable(state: CalendarBaseState): CalendarContextValues { + const { + allFocusable, + dateRangeType, + firstDayOfWeek, + firstWeekOfYear, + formatters, + highlightCurrent, + highlightSelected, + maxDate, + minDate, + restrictedDates, + setValue, + showWeekNumbers, + today, + value, + workWeekDays, + } = state; + + const calendar = React.useMemo( + () => ({ + allFocusable, + dateRangeType, + firstDayOfWeek, + firstWeekOfYear, + formatters, + highlightCurrent, + highlightSelected, + maxDate, + minDate, + restrictedDates, + setValue, + showWeekNumbers, + today, + value, + workWeekDays, + }), + [ + allFocusable, + dateRangeType, + firstDayOfWeek, + firstWeekOfYear, + formatters, + highlightCurrent, + highlightSelected, + maxDate, + minDate, + restrictedDates, + setValue, + showWeekNumbers, + today, + value, + workWeekDays, + ], + ); + + return { calendar }; +} diff --git a/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarStyles.styles.ts b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarStyles.styles.ts new file mode 100644 index 0000000000000..af498807a9794 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/components/Calendar/useCalendarStyles.styles.ts @@ -0,0 +1,158 @@ +'use client'; + +import { tokens } from '@fluentui/react-theme'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CalendarSlots, CalendarState } from './Calendar.types'; + +/** + * The pickers are not listed: each owns its own root class name. + */ +export const calendarClassNames: SlotClassNames = { + root: 'fui-Calendar', + divider: 'fui-Calendar__divider', + goToTodayButton: 'fui-Calendar__goToTodayButton', + monthPickerWrapper: 'fui-Calendar__monthPickerWrapper', + dayPicker: 'fui-Calendar__dayPicker', + monthPicker: 'fui-Calendar__monthPicker', + liveRegion: 'fui-Calendar__liveRegion', +}; + +const useRootStyles = makeStyles({ + base: { + display: 'flex', + width: 'fit-content', + }, + normalize: { + boxShadow: 'none', + boxSizing: 'border-box', + margin: '0', + padding: '0', + }, + monthPickerNotVisible: { + flexDirection: 'column', + }, +}); + +const useDividerStyles = makeStyles({ + base: { + borderRight: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + top: 0, + }, +}); + +const useMonthPickerWrapperStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + }, +}); + +const useGoTodayButtonStyles = makeStyles({ + base: { + alignSelf: 'flex-end', + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + bottom: 0, + boxSizing: 'content-box', + color: tokens.colorNeutralForeground1, + fontFamily: 'inherit', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightRegular, + minWidth: 'auto', + height: '30px', + lineHeight: '30px', + marginInlineEnd: tokens.spacingHorizontalL, + marginBlockStart: tokens.spacingVerticalXXS, + overflow: 'visible', + padding: `0 ${tokens.spacingHorizontalXS}`, + '&:hover': { + backgroundColor: tokens.colorTransparentBackground, + color: tokens.colorBrandForeground1, + cursor: 'pointer', + + '@media (forced-colors: active)': { + outline: tokens.strokeWidthThin, + borderRadius: tokens.borderRadiusSmall, + }, + }, + '&:hover:active': { + color: tokens.colorBrandForeground2, + }, + '&:disabled': { + color: tokens.colorNeutralForegroundDisabled, + pointerEvents: 'none', + }, + }, +}); + +const useLiveRegionStyles = makeStyles({ + base: { + border: 'none', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: '0', + position: 'absolute', + width: '1px', + }, +}); + +/** + * Apply styling to the Calendar slots based on the state. + */ +export const useCalendarStyles_unstable = (state: CalendarState): CalendarState => { + const rootStyles = useRootStyles(); + const dividerStyles = useDividerStyles(); + const monthPickerWrapperStyles = useMonthPickerWrapperStyles(); + const goTodayButtonStyles = useGoTodayButtonStyles(); + const liveRegionStyles = useLiveRegionStyles(); + + const { isMonthPickerVisible } = state; + + /* eslint-disable react-hooks/immutability */ + state.root.className = mergeClasses( + calendarClassNames.root, + rootStyles.base, + rootStyles.normalize, + !isMonthPickerVisible && rootStyles.monthPickerNotVisible, + state.root.className, + ); + + state.liveRegion.className = mergeClasses( + calendarClassNames.liveRegion, + liveRegionStyles.base, + state.liveRegion.className, + ); + + if (state.divider) { + state.divider.className = mergeClasses(calendarClassNames.divider, dividerStyles.base, state.divider.className); + } + + if (state.monthPickerWrapper) { + state.monthPickerWrapper.className = mergeClasses( + calendarClassNames.monthPickerWrapper, + monthPickerWrapperStyles.base, + state.monthPickerWrapper.className, + ); + } + + if (state.goToTodayButton) { + state.goToTodayButton.className = mergeClasses( + calendarClassNames.goToTodayButton, + goTodayButtonStyles.base, + state.goToTodayButton.className, + ); + } + + if (state.dayPicker) { + state.dayPicker.className = mergeClasses(calendarClassNames.dayPicker, state.dayPicker.className); + } + + if (state.monthPicker) { + state.monthPicker.className = mergeClasses(calendarClassNames.monthPicker, state.monthPicker.className); + } + + /* eslint-enable react-hooks/immutability */ + return state; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/contexts/index.ts b/packages/react-components/react-calendar-preview/library/src/contexts/index.ts new file mode 100644 index 0000000000000..f709fe7555630 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/contexts/index.ts @@ -0,0 +1,8 @@ +export { CalendarProvider, calendarContextDefaultValue, useCalendarContext_unstable } from './calendarContext'; +export type { CalendarContextValue, CalendarContextValues } from './calendarContext'; +export { CalendarDayProvider, useCalendarDayContext_unstable } from './calendarDayContext'; +export type { CalendarDayContextValue, CalendarDayContextValues } from './calendarDayContext'; +export { CalendarMonthProvider, useCalendarMonthContext_unstable } from './calendarMonthContext'; +export type { CalendarMonthCell, CalendarMonthContextValue, CalendarMonthContextValues } from './calendarMonthContext'; +export { CalendarYearProvider, useCalendarYearContext_unstable } from './calendarYearContext'; +export type { CalendarYearCell, CalendarYearContextValue, CalendarYearContextValues } from './calendarYearContext'; diff --git a/packages/react-components/react-calendar-preview/library/src/hooks/index.ts b/packages/react-components/react-calendar-preview/library/src/hooks/index.ts new file mode 100644 index 0000000000000..099ee3aaecf0b --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { useWeekCorners } from './useWeekCorners'; +export type { DayCorners, WeekCorners } from './useWeekCorners'; +export { useWeeks } from './useWeeks'; +export type { DayInfo, UseWeeksOptions } from './useWeeks'; diff --git a/packages/react-components/react-calendar-preview/library/src/index.ts b/packages/react-components/react-calendar-preview/library/src/index.ts index 4eb9888bb8d6d..27069dbe087ae 100644 --- a/packages/react-components/react-calendar-preview/library/src/index.ts +++ b/packages/react-components/react-calendar-preview/library/src/index.ts @@ -1,2 +1,234 @@ +export { + Calendar, + calendarClassNames, + renderCalendar_unstable, + useCalendarBase_unstable, + useCalendarContextValues_unstable, + useCalendarStyles_unstable, + useCalendar_unstable, +} from './Calendar'; +export type { + CalendarBaseProps, + CalendarBaseState, + CalendarContextValue, + CalendarContextValues, + CalendarDismissData, + CalendarLayout, + CalendarNavigateData, + CalendarProps, + CalendarSelectDateData, + CalendarSlots, + CalendarState, + CalendarView, + CalendarViewChangeData, +} from './Calendar'; + +export { + CalendarDay, + calendarDayClassNames, + renderCalendarDay_unstable, + useCalendarDayBase_unstable, + useCalendarDayContextValues_unstable, + useCalendarDayStyles_unstable, + useCalendarDay_unstable, +} from './CalendarDay'; +export type { + CalendarDayBaseProps, + CalendarDayBaseState, + CalendarDayCellProps, + CalendarDayContextValue, + CalendarDayContextValues, + CalendarDayDismissData, + CalendarDayHandle, + CalendarDayHeaderSelectData, + CalendarDayNavigateData, + CalendarDayProps, + CalendarDaySelectData, + CalendarDaySlots, + CalendarDayState, +} from './CalendarDay'; + +export { + CalendarDayGridCell, + calendarDayGridCellClassNames, + renderCalendarDayGridCell_unstable, + useCalendarDayGridCellStyles_unstable, + useCalendarDayGridCell_unstable, +} from './CalendarDayGridCell'; +export type { + CalendarDayGridCellProps, + CalendarDayGridCellSlots, + CalendarDayGridCellState, +} from './CalendarDayGridCell'; + +export { + CalendarDayGridHeaderCell, + calendarDayGridHeaderCellClassNames, + renderCalendarDayGridHeaderCell_unstable, + useCalendarDayGridHeaderCellBase_unstable, + useCalendarDayGridHeaderCellStyles_unstable, + useCalendarDayGridHeaderCell_unstable, +} from './CalendarDayGridHeaderCell'; +export type { + CalendarDayGridHeaderCellBaseProps, + CalendarDayGridHeaderCellProps, + CalendarDayGridHeaderCellSlots, + CalendarDayGridHeaderCellState, +} from './CalendarDayGridHeaderCell'; + +export { + CalendarDayGridHeaderRow, + calendarDayGridHeaderRowClassNames, + renderCalendarDayGridHeaderRow_unstable, + useCalendarDayGridHeaderRowBase_unstable, + useCalendarDayGridHeaderRowStyles_unstable, + useCalendarDayGridHeaderRow_unstable, +} from './CalendarDayGridHeaderRow'; +export type { + CalendarDayGridHeaderRowProps, + CalendarDayGridHeaderRowSlots, + CalendarDayGridHeaderRowState, + CalendarWeekDayLabel, +} from './CalendarDayGridHeaderRow'; + +export { + CalendarDayGridRow, + calendarDayGridRowClassNames, + renderCalendarDayGridRow_unstable, + useCalendarDayGridRowBase_unstable, + useCalendarDayGridRowStyles_unstable, + useCalendarDayGridRow_unstable, +} from './CalendarDayGridRow'; +export type { + CalendarDayGridRowBaseProps, + CalendarDayGridRowProps, + CalendarDayGridRowSlots, + CalendarDayGridRowState, +} from './CalendarDayGridRow'; + +export { + CalendarMonth, + calendarMonthClassNames, + renderCalendarMonth_unstable, + useCalendarMonthBase_unstable, + useCalendarMonthContextValues_unstable, + useCalendarMonthStyles_unstable, + useCalendarMonth_unstable, +} from './CalendarMonth'; +export type { + CalendarMonthBaseProps, + CalendarMonthBaseState, + CalendarMonthCell, + CalendarMonthContextValue, + CalendarMonthContextValues, + CalendarMonthHandle, + CalendarMonthHeaderSelectData, + CalendarMonthNavigateData, + CalendarMonthProps, + CalendarMonthSelectData, + CalendarMonthSlots, + CalendarMonthState, +} from './CalendarMonth'; + +export { + CalendarMonthGridCell, + calendarMonthGridCellClassNames, + renderCalendarMonthGridCell_unstable, + useCalendarMonthGridCellStyles_unstable, + useCalendarMonthGridCell_unstable, +} from './CalendarMonthGridCell'; +export type { + CalendarMonthGridCellProps, + CalendarMonthGridCellSlots, + CalendarMonthGridCellState, +} from './CalendarMonthGridCell'; + +export { + CalendarMonthGridRow, + calendarMonthGridRowClassNames, + renderCalendarMonthGridRow_unstable, + useCalendarMonthGridRowBase_unstable, + useCalendarMonthGridRowStyles_unstable, + useCalendarMonthGridRow_unstable, +} from './CalendarMonthGridRow'; +export type { + CalendarMonthGridRowProps, + CalendarMonthGridRowSlots, + CalendarMonthGridRowState, +} from './CalendarMonthGridRow'; + +export { + CalendarYear, + calendarYearClassNames, + renderCalendarYear_unstable, + useCalendarYearBase_unstable, + useCalendarYearContextValues_unstable, + useCalendarYearStyles_unstable, + useCalendarYear_unstable, +} from './CalendarYear'; +export type { + CalendarYearBaseProps, + CalendarYearBaseState, + CalendarYearCell, + CalendarYearContextValue, + CalendarYearContextValues, + CalendarYearHandle, + CalendarYearHeaderSelectData, + CalendarYearNavigateData, + CalendarYearProps, + CalendarYearRange, + CalendarYearSlots, + CalendarYearState, + CalendarYearSelectData, +} from './CalendarYear'; + +export { + CalendarYearGridCell, + calendarYearGridCellClassNames, + renderCalendarYearGridCell_unstable, + useCalendarYearGridCellStyles_unstable, + useCalendarYearGridCell_unstable, +} from './CalendarYearGridCell'; +export type { + CalendarYearGridCellProps, + CalendarYearGridCellSlots, + CalendarYearGridCellState, +} from './CalendarYearGridCell'; + +export { + CalendarYearGridRow, + calendarYearGridRowClassNames, + renderCalendarYearGridRow_unstable, + useCalendarYearGridRowBase_unstable, + useCalendarYearGridRowStyles_unstable, + useCalendarYearGridRow_unstable, +} from './CalendarYearGridRow'; +export type { + CalendarYearGridRowProps, + CalendarYearGridRowSlots, + CalendarYearGridRowState, +} from './CalendarYearGridRow'; + export { CalendarProvider, calendarContextDefaultValue, useCalendarContext_unstable } from './contexts/calendarContext'; -export type { CalendarContextValue, CalendarContextValues } from './contexts/calendarContext'; + +export { + calendarFormatters, + createCalendarDateTimeFormatter, + addDays, + addMonths, + addWeeks, + addYears, + getDateRange, +} from './utils'; +export type { + AnimationDirection, + CalendarDateLabelData, + CalendarDateTimeFormat, + CalendarDateTimeFormatterOptions, + CalendarFormatters, + CalendarYearRangeLabelData, + DateRangeType, + DayOfWeek, + FirstWeekOfYear, + MonthOfYear, +} from './utils'; diff --git a/packages/react-components/react-calendar-preview/library/src/testing/isConformant.ts b/packages/react-components/react-calendar-preview/library/src/testing/isConformant.ts new file mode 100644 index 0000000000000..8ed2da0f92513 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/testing/isConformant.ts @@ -0,0 +1,15 @@ +import { isConformant as baseIsConformant } from '@fluentui/react-conformance'; +import type { IsConformantOptions, TestObject } from '@fluentui/react-conformance'; +import griffelTests from '@fluentui/react-conformance-griffel'; + +export function isConformant( + testInfo: Omit, 'componentPath'> & { componentPath?: string }, +): void { + const defaultOptions: Partial> = { + tsConfig: { configName: 'tsconfig.spec.json' }, + componentPath: require.main?.filename.replace('.test', ''), + extraTests: griffelTests as TestObject, + }; + + baseIsConformant(defaultOptions, testInfo); +} diff --git a/packages/react-components/react-calendar-preview/library/tsconfig.cy.json b/packages/react-components/react-calendar-preview/library/tsconfig.cy.json new file mode 100644 index 0000000000000..3cfb39a1086ac --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/tsconfig.cy.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "types": ["node", "cypress", "cypress-real-events"], + "typeRoots": ["../../../../node_modules", "../../../../node_modules/@types"], + "lib": ["ES2019", "dom"] + }, + "include": ["**/*.cy.ts", "**/*.cy.tsx"] +} diff --git a/packages/react-components/react-calendar-preview/library/tsconfig.json b/packages/react-components/react-calendar-preview/library/tsconfig.json index 32bdbdf1ac26f..7a15fb89d7f23 100644 --- a/packages/react-components/react-calendar-preview/library/tsconfig.json +++ b/packages/react-components/react-calendar-preview/library/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.cy.json" } ] } diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarBestPractices.md b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarBestPractices.md new file mode 100644 index 0000000000000..2f195f7063245 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarBestPractices.md @@ -0,0 +1,40 @@ +## Best practices + +### Layout + +- Use `layout="auto"` for responsive day/month views. Use `monthPicker={null}` for day-only mode or `dayPicker={null}` for month-only selection. +- Prefer the composed Calendar for normal date selection. Independent pickers require a `CalendarProvider` with shared selection and configuration. +- Preserve the built-in grid roles and keyboard navigation when customizing cells. Use `dayPicker.getDayCellProps` and `monthPicker.yearPicker.renderYear` instead of replacing the grid. + +### State and focus + +- Use `value={null}` for an empty controlled selection, or `defaultValue={null}` for an empty uncontrolled selection. Do not switch between controlled and uncontrolled mode. +- Selection and navigation are separate. Control `displayedDate` and `onDisplayedDateChange` when the application owns browsing; otherwise external `value` changes navigate automatically. +- Use `view` and `onViewChange` to control the overlay picker. Use `dayPicker={null}`, not a locked month view, to commit month selections. +- Use child picker refs for initial focus. A surrounding Popover or Dialog owns popup positioning, focus trapping, and return focus; Calendar reports dismissal through `onDismiss`. +- In standalone day pickers, honor `onNavigateDate`'s `focusOnNavigatedDay` request by calling the picker handle's `focus()` on the next animation frame after the new date renders. + +### Content + +- Follow the user's locale for date ordering, month names, and weekday names. Use `createCalendarDateTimeFormatter` to create reusable Intl-backed date formatters. +- Don't use ordinal numbers (such as 1st, 12th, or 23rd) to indicate a date. +- The control provides English labels and date formatting by default. Use `formatters` to override date values and complete accessible labels. +- Omitted formatter properties use the defaults. Override only the properties needed for customization: + + ```tsx + import { Calendar } from '@fluentui/react-calendar-preview'; + + `Week ${data.weekNumber}` }} showWeekNumbers />; + ``` + +- Use the relevant slot prop to replace or suppress a concrete label attribute instead of returning `undefined` from a formatter. + +- Localize every label formatter, the Go to today text, and the close-button label when shown. Overriding `dateTime` alone leaves English accessible labels. The Localized Formatting story shows a complete configuration. +- Configure `firstDayOfWeek` and `firstWeekOfYear` for the locale; formatting does not infer week rules. +- Locale extensions and the formatter's `timeZone` option affect labels only. The grid and selected `Date` values still use Gregorian local-date arithmetic. + + ```tsx + import { createCalendarDateTimeFormatter } from '@fluentui/react-calendar-preview'; + + const dateTime = createCalendarDateTimeFormatter('en-GB'); + ``` diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarContiguousWork.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarContiguousWork.stories.tsx new file mode 100644 index 0000000000000..1f8869aaf3984 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarContiguousWork.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps, DayOfWeek } from '@fluentui/react-calendar-preview'; + +const workWeekDays: DayOfWeek[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + +export const CalendarContiguousWorkWeekDays = (): JSXElement => { + const [selectedDateRange, setSelectedDateRange] = React.useState(); + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + setSelectedDateRange(data.selectedDateRange); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + + ); +}; + +CalendarContiguousWorkWeekDays.parameters = { + docs: { + description: { + story: 'A Calendar can be modified to allow selecting a contiguous (5-day) work week.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledNavigation.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledNavigation.stories.tsx new file mode 100644 index 0000000000000..72250beea7114 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledNavigation.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Button, makeStyles, Radio, RadioGroup, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { ArrowLeftRegular, ArrowRightRegular } from '@fluentui/react-icons'; +import { addMonths, Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarView } from '@fluentui/react-calendar-preview'; + +const useStyles = makeStyles({ + root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }, + actions: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalS }, +}); + +export const CalendarControlledNavigation = (): JSXElement => { + const styles = useStyles(); + const [value, setValue] = React.useState(null); + const [displayedDate, setDisplayedDate] = React.useState(() => new Date()); + const [view, setView] = React.useState('day'); + + return ( +
+
Selected date: {value?.toDateString() ?? 'Not set'}
+ setView(data.value as CalendarView)} + > + + + + setValue(data.date)} + displayedDate={displayedDate} + onDisplayedDateChange={(_event, data) => setDisplayedDate(data.displayedDate)} + view={view} + onViewChange={(_event, data) => setView(data.view)} + layout="overlay" + /> +
+ + +
+
+ ); +}; + +CalendarControlledNavigation.parameters = { + docs: { + description: { + story: + 'Control displayedDate and view independently from value. Accept navigation and view-change requests in their corresponding callbacks; navigation does not commit a selection.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledSelection.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledSelection.stories.tsx new file mode 100644 index 0000000000000..916e53ab43ef7 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarControlledSelection.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { ArrowLeftRegular, ArrowRightRegular, DismissRegular } from '@fluentui/react-icons'; +import { addMonths, Calendar } from '@fluentui/react-calendar-preview'; + +const useStyles = makeStyles({ + root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }, + actions: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalS }, +}); + +export const CalendarControlledSelection = (): JSXElement => { + const styles = useStyles(); + const [value, setValue] = React.useState(null); + + return ( +
+
Selected date: {value?.toDateString() ?? 'Not set'}
+ setValue(data.date)} /> +
+ + + +
+
+ ); +}; + +CalendarControlledSelection.parameters = { + docs: { + description: { + story: + 'Use null for an empty controlled selection. External value changes navigate to the new date unless displayedDate is also controlled. Clearing selection preserves the displayed month.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarCustomCells.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarCustomCells.stories.tsx new file mode 100644 index 0000000000000..dc7a347a3d98f --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarCustomCells.stories.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { CalendarRegular } from '@fluentui/react-icons'; +import { Calendar } from '@fluentui/react-calendar-preview'; + +const useStyles = makeStyles({ + root: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: tokens.spacingVerticalM }, + appointment: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold }, +}); + +export const CalendarCustomCells = (): JSXElement => { + const styles = useStyles(); + const appointmentRef = React.useRef(null); + + return ( +
+ + date.getDate() === 15 + ? { + ref: appointmentRef, + title: 'Monthly appointment', + dayLabel: { className: styles.appointment }, + } + : {}, + }} + monthPicker={{ + yearPicker: { + renderYear: year => {year}, + }, + }} + /> + +
+ ); +}; + +CalendarCustomCells.parameters = { + docs: { + description: { + story: + 'getDayCellProps customizes each visible day, including refs and child slots, without replacing the grid. ' + + 'CalendarYear.renderYear customizes year content. Cell click and keyboard handlers run first; preventDefault cancels the built-in action.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDateBoundaries.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDateBoundaries.stories.tsx new file mode 100644 index 0000000000000..7ffe752d4f14b --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDateBoundaries.stories.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Checkbox } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { addMonths, addYears, addDays, Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const CalendarDateBoundaries = (): JSXElement => { + const today = new Date(); + const minDate = addMonths(today, -1); + const maxDate = addYears(today, 1); + const restrictedDates = [addDays(today, -2), addDays(today, -8), addDays(today, 2), addDays(today, 8)]; + + const [selectedDate, setSelectedDate] = React.useState(null); + const [allFocusable, setAllFocusable] = React.useState(false); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
+ Date boundary: {minDate.toDateString()}-{maxDate.toDateString()} +
+
Disabled dates: {restrictedDates.map(d => d.toDateString()).join(', ')}
+ setAllFocusable(data.checked === true)} + /> + + + ); +}; + +CalendarDateBoundaries.parameters = { + docs: { + description: { + story: + 'A Calendar can be modified to set a minDate and maxDate in order to restrict ' + + 'the dates that can be selected.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDefault.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDefault.stories.tsx new file mode 100644 index 0000000000000..fde4a24fa0fb8 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDefault.stories.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const Default = (props: CalendarProps): JSXElement => ; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDescription.md b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDescription.md new file mode 100644 index 0000000000000..4094f6eba752c --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarDescription.md @@ -0,0 +1 @@ +The calendar control lets people select and view a single date or a range of dates in their calendar. It’s made up of 3 separate views: the month view, year view, and decade view. diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarFocusAndDismissal.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarFocusAndDismissal.stories.tsx new file mode 100644 index 0000000000000..499f3e597bfcb --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarFocusAndDismissal.stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Button, Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { CalendarRegular } from '@fluentui/react-icons'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarDayHandle } from '@fluentui/react-calendar-preview'; + +export const CalendarFocusAndDismissal = (): JSXElement => { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(null); + const dayRef = React.useRef(null); + + React.useEffect(() => { + if (open) { + dayRef.current?.focus(); + } + }, [open]); + + return ( + setOpen(data.open)} trapFocus> + + + + + { + setValue(data.date); + setOpen(false); + }} + onDismiss={() => setOpen(false)} + /> + + + ); +}; + +CalendarFocusAndDismissal.parameters = { + docs: { + description: { + story: + 'Use the day picker handle for initial focus. Calendar reports Escape and close-button dismissal; ' + + 'the surrounding Popover owns positioning, light dismissal, the focus trap, and return focus.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarLocalizedFormatting.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarLocalizedFormatting.stories.tsx new file mode 100644 index 0000000000000..1143e20089d36 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarLocalizedFormatting.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar, createCalendarDateTimeFormatter } from '@fluentui/react-calendar-preview'; +import type { CalendarFormatters } from '@fluentui/react-calendar-preview'; + +const formatters: CalendarFormatters = { + dateTime: createCalendarDateTimeFormatter('de-DE'), + previousMonthLabel: data => `Vorheriger Monat ${data.formattedDate}`, + nextMonthLabel: data => `Folgender Monat ${data.formattedDate}`, + previousYearLabel: data => `Vorheriges Jahr ${data.formattedDate}`, + nextYearLabel: data => `Folgendes Jahr ${data.formattedDate}`, + previousYearRangeLabel: data => `Vorherige Jahre ${data.formattedRange}`, + nextYearRangeLabel: data => `Weitere Jahre ${data.formattedRange}`, + monthPickerHeaderLabel: data => `${data.formattedDate}, Jahr wechseln`, + yearPickerHeaderLabel: data => `${data.formattedDate}, Monat wechseln`, + yearRangePickerHeaderLabel: data => `${data.formattedRange}, Jahr wechseln`, + weekNumberLabel: data => `Kalenderwoche ${data.weekNumber}`, + selectedDateLabel: data => `Auswahl: ${data.formattedDate}`, + todayDateLabel: data => `Heute: ${data.formattedDate}`, + dayMarkedLabel: data => `${data.formattedDate}, markiert`, +}; + +export const CalendarLocalizedFormatting = (): JSXElement => ( + +); + +CalendarLocalizedFormatting.parameters = { + docs: { + description: { + story: + 'A fully localized Calendar uses createCalendarDateTimeFormatter for dates, all label formatters for accessibility, ' + + 'and slot props for button text. Week layout and week-number rules are configured separately.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMarkedDays.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMarkedDays.stories.tsx new file mode 100644 index 0000000000000..b4ee3b97265e2 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMarkedDays.stories.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar, addDays } from '@fluentui/react-calendar-preview'; +import type { CalendarDayProps, CalendarProps } from '@fluentui/react-calendar-preview'; + +const dayPicker: Partial = { + getMarkedDays: (startingDate, _) => [addDays(startingDate, 3), addDays(startingDate, 4)], +}; + +export const CalendarMarkedDays = (): JSXElement => { + const [selectedDate, setSelectedDate] = React.useState(new Date()); + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+ + + ); +}; + +CalendarMarkedDays.parameters = { + docs: { + description: { + story: + 'A Calendar allows you to pass a callback that returns an array of dates that should be ' + + 'marked. This callback provides a starting date and an ending date.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthOnly.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthOnly.stories.tsx new file mode 100644 index 0000000000000..72a515118ceda --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthOnly.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const CalendarMonthOnly = (): JSXElement => { + const [selectedDateRange, setSelectedDateRange] = React.useState(); + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + setSelectedDateRange(data.selectedDateRange); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + + ); +}; + +CalendarMonthOnly.parameters = { + docs: { + description: { + story: 'A Calendar allows you to only show the month and year picker, leaving the day picker hidden.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthSelection.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthSelection.stories.tsx new file mode 100644 index 0000000000000..2373f4344f6c9 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMonthSelection.stories.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar, addDays, getDateRange } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; +import { Button, makeStyles } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + wrapper: { height: '360px' }, +}); +const dateRangeType = 'month'; +const firstDayOfWeek = 'sunday'; + +export const CalendarMonthSelection = (): JSXElement => { + const styles = useStyles(); + const [selectedDate, setSelectedDate] = React.useState(null); + const selectedDateRange = selectedDate ? getDateRange(selectedDate, dateRangeType, firstDayOfWeek) : undefined; + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + const goPrevious = React.useCallback((): void => { + setSelectedDate(prevSelectedDate => { + prevSelectedDate = prevSelectedDate || new Date(); + const dateRangeArray = getDateRange(prevSelectedDate, dateRangeType, firstDayOfWeek); + + const subtractFrom = new Date(dateRangeArray[0].getFullYear(), dateRangeArray[0].getMonth(), 1); + const daysToSubtract = 1; + + return addDays(subtractFrom, -daysToSubtract); + }); + }, []); + + const goNext = React.useCallback((): void => { + setSelectedDate(prevSelectedDate => { + prevSelectedDate = prevSelectedDate || new Date(); + const dateRangeArray = getDateRange(prevSelectedDate, dateRangeType, firstDayOfWeek); + return addDays(dateRangeArray.pop()!, 1); + }); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( +
+
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + +
+ + +
+
+ ); +}; + +CalendarMonthSelection.parameters = { + docs: { + description: { + story: 'A Calendar allows you to set a selection range of months instead of selecting a single day.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMultiDayView.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMultiDayView.stories.tsx new file mode 100644 index 0000000000000..97fb3a416b4e1 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarMultiDayView.stories.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; +import { Dropdown, Field, makeStyles, Option } from '@fluentui/react-components'; +import type { SelectionEvents, OptionOnSelectData } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + wrapper: { height: '360px' }, + dropdown: { width: '230px' }, +}); + +const dayOptions = ['1', '2', '3', '4', '5', '6']; + +export const CalendarMultidayDayView = (): JSXElement => { + const styles = useStyles(); + const [selectedDateRange, setSelectedDateRange] = React.useState(); + const [selectedDate, setSelectedDate] = React.useState(null); + const [daysToSelectInDayView, setDaysToSelectInDayView] = React.useState(4); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + setSelectedDateRange(data.selectedDateRange); + }, []); + + const onOptionSelect = React.useCallback((_: SelectionEvents, data: OptionOnSelectData) => { + setDaysToSelectInDayView(Number(data.optionText)); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( +
+

+ This calendar uses dateRangeType="day" and{' '} + dayPicker.daysToSelectInDayView = {daysToSelectInDayView}. +

+
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + + + {dayOptions.map(option => ( + + ))} + + +

Selection with negative date range

+ +
+ ); +}; + +CalendarMultidayDayView.parameters = { + docs: { + description: { + story: + 'A Calendar allows you to pass a number of days that will be highlighted from the selected date' + + ' and forward.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarNonContiguousWorkWeekDays.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarNonContiguousWorkWeekDays.stories.tsx new file mode 100644 index 0000000000000..90537680c2250 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarNonContiguousWorkWeekDays.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps, DayOfWeek } from '@fluentui/react-calendar-preview'; + +const workWeekDays: DayOfWeek[] = ['tuesday', 'saturday', 'wednesday', 'friday']; + +export const CalendarNonContiguousWorkWeekDays = (): JSXElement => { + const [selectedDateRange, setSelectedDateRange] = React.useState(); + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + setSelectedDateRange(data.selectedDateRange); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + + + ); +}; + +CalendarNonContiguousWorkWeekDays.parameters = { + docs: { + description: { + story: 'A Calendar can be modified to allow selecting a non-contiguous (7-day) week.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarOverlaidMonthPicker.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarOverlaidMonthPicker.stories.tsx new file mode 100644 index 0000000000000..5dac76dc08aea --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarOverlaidMonthPicker.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const CalendarOverlaidMonth = (): JSXElement => { + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+ + + + ); +}; + +CalendarOverlaidMonth.parameters = { + docs: { + description: { + story: + 'A Calendar allows you to render the month picker over the day picker. This is useful' + + ' when there are width constraints and the month picker is needed.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarSixWeeks.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarSixWeeks.stories.tsx new file mode 100644 index 0000000000000..b86f509f042f3 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarSixWeeks.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const CalendarSixWeeks = (): JSXElement => { + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+ + + + ); +}; + +CalendarSixWeeks.parameters = { + docs: { + description: { + story: 'A Calendar allows you to set a six-week month.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarStandalonePickers.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarStandalonePickers.stories.tsx new file mode 100644 index 0000000000000..ec926fe7c6f82 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarStandalonePickers.stories.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { makeStyles, Text, tokens, useAnimationFrame } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { + addYears, + CalendarDay, + CalendarMonth, + CalendarProvider, + CalendarYear, + calendarContextDefaultValue, +} from '@fluentui/react-calendar-preview'; +import type { CalendarDayHandle, CalendarProps } from '@fluentui/react-calendar-preview'; + +const useStyles = makeStyles({ + pickers: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalXL }, +}); + +export const CalendarStandalonePickers = (): JSXElement => { + const styles = useStyles(); + const [requestAnimationFrame] = useAnimationFrame(); + const [today] = React.useState(() => new Date()); + const [value, setValue] = React.useState(null); + const [displayedDate, setDisplayedDate] = React.useState(today); + const dayPickerRef = React.useRef(null); + const focusDayOnUpdate = React.useRef(false); + const onSelectDate: NonNullable = (_event, data) => setValue(data.date); + + React.useEffect(() => { + if (focusDayOnUpdate.current) { + requestAnimationFrame(() => dayPickerRef.current?.focus()); + focusDayOnUpdate.current = false; + } + }); + + return ( + +
Selected date: {value?.toDateString() ?? 'Not set'}
+
+
+ + Day + + { + focusDayOnUpdate.current = data.focusOnNavigatedDay; + setDisplayedDate(data.date); + }} + /> +
+
+ + Month + + setDisplayedDate(data.date)} + onSelectDate={onSelectDate} + /> +
+
+ + Year + + { + const date = addYears(displayedDate, data.year - displayedDate.getFullYear()); + setValue(date); + setDisplayedDate(date); + }} + /> +
+
+
+ ); +}; + +CalendarStandalonePickers.parameters = { + docs: { + description: { + story: + 'CalendarProvider supplies selection, boundaries, range settings, and formatters to independent pickers. ' + + 'Start from calendarContextDefaultValue, handle day selection through setValue, and handle month/year selection through their callbacks. ' + + 'Each picker reports navigation separately from selection. Restore day focus after navigation when focusOnNavigatedDay is requested.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekNumbers.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekNumbers.stories.tsx new file mode 100644 index 0000000000000..b563f96923d38 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekNumbers.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; + +export const CalendarWeekNumbers = (): JSXElement => { + const [selectedDate, setSelectedDate] = React.useState(null); + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + return ( + <> +
Selected date: {selectedDate?.toDateString() || 'Not set'}
+ + + + ); +}; + +CalendarWeekNumbers.parameters = { + docs: { + description: { + story: 'A Calendar allows you to show the week numbers next to the day grid for their respective week.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekSelection.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekSelection.stories.tsx new file mode 100644 index 0000000000000..d1ef433659953 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/CalendarWeekSelection.stories.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { addDays, getDateRange, Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; +import { Button, makeStyles } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + wrapper: { height: '360px' }, +}); +const dateRangeType = 'week'; +const firstDayOfWeek = 'sunday'; + +export const CalendarWeekSelection = (): JSXElement => { + const styles = useStyles(); + const [selectedDate, setSelectedDate] = React.useState(null); + const selectedDateRange = selectedDate ? getDateRange(selectedDate, dateRangeType, firstDayOfWeek) : undefined; + + const onSelectDate: NonNullable = React.useCallback((_event, data) => { + setSelectedDate(data.date); + }, []); + + const goPrevious = React.useCallback((): void => { + setSelectedDate(prevSelectedDate => { + prevSelectedDate = prevSelectedDate || new Date(); + const dateRangeArray = getDateRange(prevSelectedDate, dateRangeType, firstDayOfWeek); + + return addDays(dateRangeArray[0], -1); + }); + }, []); + + const goNext = React.useCallback((): void => { + setSelectedDate(prevSelectedDate => { + prevSelectedDate = prevSelectedDate || new Date(); + const dateRangeArray = getDateRange(prevSelectedDate, dateRangeType, firstDayOfWeek); + return addDays(dateRangeArray.pop()!, 1); + }); + }, []); + + let dateRangeString = 'Not set'; + if (selectedDateRange) { + const rangeStart = selectedDateRange[0]; + const rangeEnd = selectedDateRange[selectedDateRange.length - 1]; + dateRangeString = rangeStart.toDateString() + '-' + rangeEnd.toDateString(); + } + + return ( +
+
Selected date: {selectedDate?.toDateString() || 'Not set'}
+
Selected range: {dateRangeString}
+ + +
+ + +
+
+ ); +}; + +CalendarWeekSelection.parameters = { + docs: { + description: { + story: 'A Calendar allows you to set a selection range of weeks instead of selecting a single day.', + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/Calendar/index.stories.tsx b/packages/react-components/react-calendar-preview/stories/src/Calendar/index.stories.tsx new file mode 100644 index 0000000000000..32d6b06ccff46 --- /dev/null +++ b/packages/react-components/react-calendar-preview/stories/src/Calendar/index.stories.tsx @@ -0,0 +1,35 @@ +import { Calendar } from '@fluentui/react-calendar-preview'; + +import descriptionMd from './CalendarDescription.md'; +import bestPracticesMd from './CalendarBestPractices.md'; + +export { Default } from './CalendarDefault.stories'; +export { CalendarOverlaidMonth } from './CalendarOverlaidMonthPicker.stories'; +export { CalendarDateBoundaries } from './CalendarDateBoundaries.stories'; +export { CalendarSixWeeks } from './CalendarSixWeeks.stories'; +export { CalendarWeekNumbers } from './CalendarWeekNumbers.stories'; +export { CalendarWeekSelection } from './CalendarWeekSelection.stories'; +export { CalendarMarkedDays } from './CalendarMarkedDays.stories'; +export { CalendarMonthOnly } from './CalendarMonthOnly.stories'; +export { CalendarMonthSelection } from './CalendarMonthSelection.stories'; +export { CalendarMultidayDayView } from './CalendarMultiDayView.stories'; +export { CalendarContiguousWorkWeekDays } from './CalendarContiguousWork.stories'; +export { CalendarNonContiguousWorkWeekDays } from './CalendarNonContiguousWorkWeekDays.stories'; +export { CalendarLocalizedFormatting } from './CalendarLocalizedFormatting.stories'; +export { CalendarControlledSelection } from './CalendarControlledSelection.stories'; +export { CalendarControlledNavigation } from './CalendarControlledNavigation.stories'; +export { CalendarStandalonePickers } from './CalendarStandalonePickers.stories'; +export { CalendarCustomCells } from './CalendarCustomCells.stories'; +export { CalendarFocusAndDismissal } from './CalendarFocusAndDismissal.stories'; + +export default { + title: 'Preview Components/Calendar', + component: Calendar, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +}; diff --git a/packages/react-components/react-calendar-preview/stories/src/index.ts b/packages/react-components/react-calendar-preview/stories/src/index.ts index cb0cb3c9b3b7a..cb0ff5c3b541f 100644 --- a/packages/react-components/react-calendar-preview/stories/src/index.ts +++ b/packages/react-components/react-calendar-preview/stories/src/index.ts @@ -1,2 +1 @@ -// Stories are added in a follow-up PR. export {}; diff --git a/yarn.lock b/yarn.lock index df965d89b86b9..989cdb2a9099b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3291,6 +3291,7 @@ __metadata: dependencies: "@fluentui/react": "npm:*" "@fluentui/react-calendar-compat": "npm:*" + "@fluentui/react-calendar-preview": "npm:*" "@fluentui/react-components": "npm:*" "@fluentui/react-datepicker-compat": "npm:*" "@fluentui/react-icons-compat": "npm:*" @@ -3600,17 +3601,16 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-calendar-preview@workspace:packages/react-components/react-calendar-preview/library": +"@fluentui/react-calendar-preview@npm:*, @fluentui/react-calendar-preview@workspace:packages/react-components/react-calendar-preview/library": version: 0.0.0-use.local resolution: "@fluentui/react-calendar-preview@workspace:packages/react-components/react-calendar-preview/library" dependencies: "@fluentui/keyboard-keys": "npm:^9.0.9" - "@fluentui/react-button": "npm:^9.11.0" "@fluentui/react-context-selector": "npm:^9.2.19" "@fluentui/react-icons": "npm:^2.0.245" "@fluentui/react-jsx-runtime": "npm:^9.4.5" "@fluentui/react-motion": "npm:^9.16.2" - "@fluentui/react-motion-components-preview": "npm:^0.15.7" + "@fluentui/react-motion-components-preview": "npm:^0.15.8" "@fluentui/react-shared-contexts": "npm:^9.26.3" "@fluentui/react-tabster": "npm:^9.26.17" "@fluentui/react-theme": "npm:^9.2.2" @@ -4961,7 +4961,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-motion-components-preview@npm:*, @fluentui/react-motion-components-preview@npm:^0.15.7, @fluentui/react-motion-components-preview@npm:^0.15.8, @fluentui/react-motion-components-preview@workspace:packages/react-components/react-motion-components-preview/library": +"@fluentui/react-motion-components-preview@npm:*, @fluentui/react-motion-components-preview@npm:^0.15.8, @fluentui/react-motion-components-preview@workspace:packages/react-components/react-motion-components-preview/library": version: 0.0.0-use.local resolution: "@fluentui/react-motion-components-preview@workspace:packages/react-components/react-motion-components-preview/library" dependencies: