From 2737dd76099e41917288d1c23c96fc3fd3f10382 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 17 Oct 2026 12:12:35 +0200 Subject: [PATCH 01/11] feat(react-datepicker-preview): scaffold package and specify v9 DatePicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Previous Behavior DatePicker Compat provides the existing date-input experience. There is no non-compat DatePicker preview package on this branch's base. ## New Behavior Adds the private DatePicker preview library and stories scaffold, workspace registration, and ownership entries. Defines the proposed non-compat DatePicker API and implementation requirements: compat visual/behavioral parity, Calendar Preview composition, v9 slots and event/data callbacks, validation, focus/accessibility, and a compat migration walkthrough. The scaffold is not a working DatePicker and does not implement the proposed API. ### Planned stack 1. This PR: current scaffold, workspace wiring, spec, and migration guide. 2. Dependent PR: styled DatePicker implementation, shared base hooks suitable for headless reuse, tests, and stories. 3. Dependent PR: headless DatePicker using the base hooks from layer 2, with its integration, tests, and stories. Layers 2 and 3 are deferred pending resolution of the component-package dependency prerequisite documented in the spec. No new DatePicker-to-Calendar/Input component dependency is introduced here. ### Validation - `yarn nx run-many -p react-datepicker-preview react-datepicker-preview-stories -t test lint type-check --parallel=3` — passed, including library/story lint and type checks, scaffold tests (20 tests and one snapshot), and dependency builds. - `git diff --cached --check` — passed before committing. - Runtime parity, browser interaction, visual regression, and screen-reader validation — not run; the component is still a scaffold. - Beachball change file — not added: the new library and stories packages are private and this layer changes no published package. ## Related Issue(s) - Fixes # — no linked issue provided. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 2 + .../library/.babelrc.json | 4 + .../react-datepicker-preview/library/.swcrc | 31 + .../react-datepicker-preview/library/LICENSE | 15 + .../library/README.md | 5 + .../library/config/api-extractor.json | 5 + .../library/config/tests.cjs | 1 + .../library/docs/Spec.md | 631 ++++++++++++++++++ .../library/eslint.config.cjs | 5 + .../etc/react-datepicker-preview.api.md | 38 ++ .../library/jest.config.cjs | 34 + .../library/package.json | 59 ++ .../library/project.json | 8 + .../library/src/DatePicker.ts | 8 + .../components/DatePicker/DatePicker.test.tsx | 18 + .../src/components/DatePicker/DatePicker.tsx | 30 + .../components/DatePicker/DatePicker.types.ts | 17 + .../__snapshots__/DatePicker.test.tsx.snap | 11 + .../src/components/DatePicker/index.ts | 5 + .../DatePicker/renderDatePicker.tsx | 16 + .../components/DatePicker/useDatePicker.ts | 31 + .../DatePicker/useDatePickerStyles.styles.ts | 37 + .../library/src/index.ts | 8 + .../library/src/testing/isConformant.ts | 15 + .../library/tsconfig.json | 22 + .../library/tsconfig.lib.json | 22 + .../library/tsconfig.spec.json | 17 + .../stories/.storybook/main.cjs | 14 + .../stories/.storybook/preview.js | 9 + .../stories/.storybook/tsconfig.json | 10 + .../stories/README.md | 17 + .../stories/eslint.config.js | 10 + .../stories/package.json | 5 + .../stories/project.json | 8 + .../stories/src/.gitkeep | 0 .../src/DatePicker/DatePickerBestPractices.md | 5 + .../DatePicker/DatePickerDefault.stories.tsx | 5 + .../src/DatePicker/DatePickerDescription.md | 0 .../stories/src/DatePicker/index.stories.tsx | 18 + .../stories/src/index.ts | 1 + .../stories/tsconfig.json | 22 + .../stories/tsconfig.lib.json | 10 + tsconfig.base.all.json | 8 +- tsconfig.base.json | 4 + yarn.lock | 25 + 45 files changed, 1264 insertions(+), 2 deletions(-) create mode 100644 packages/react-components/react-datepicker-preview/library/.babelrc.json create mode 100644 packages/react-components/react-datepicker-preview/library/.swcrc create mode 100644 packages/react-components/react-datepicker-preview/library/LICENSE create mode 100644 packages/react-components/react-datepicker-preview/library/README.md create mode 100644 packages/react-components/react-datepicker-preview/library/config/api-extractor.json create mode 100644 packages/react-components/react-datepicker-preview/library/config/tests.cjs create mode 100644 packages/react-components/react-datepicker-preview/library/docs/Spec.md create mode 100644 packages/react-components/react-datepicker-preview/library/eslint.config.cjs create mode 100644 packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md create mode 100644 packages/react-components/react-datepicker-preview/library/jest.config.cjs create mode 100644 packages/react-components/react-datepicker-preview/library/package.json create mode 100644 packages/react-components/react-datepicker-preview/library/project.json create mode 100644 packages/react-components/react-datepicker-preview/library/src/DatePicker.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/index.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/testing/isConformant.ts create mode 100644 packages/react-components/react-datepicker-preview/library/tsconfig.json create mode 100644 packages/react-components/react-datepicker-preview/library/tsconfig.lib.json create mode 100644 packages/react-components/react-datepicker-preview/library/tsconfig.spec.json create mode 100644 packages/react-components/react-datepicker-preview/stories/.storybook/main.cjs create mode 100644 packages/react-components/react-datepicker-preview/stories/.storybook/preview.js create mode 100644 packages/react-components/react-datepicker-preview/stories/.storybook/tsconfig.json create mode 100644 packages/react-components/react-datepicker-preview/stories/README.md create mode 100644 packages/react-components/react-datepicker-preview/stories/eslint.config.js create mode 100644 packages/react-components/react-datepicker-preview/stories/package.json create mode 100644 packages/react-components/react-datepicker-preview/stories/project.json create mode 100644 packages/react-components/react-datepicker-preview/stories/src/.gitkeep create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/index.ts create mode 100644 packages/react-components/react-datepicker-preview/stories/tsconfig.json create mode 100644 packages/react-components/react-datepicker-preview/stories/tsconfig.lib.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0e45ae92510bd..6e4d91aa46aad 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -339,6 +339,8 @@ packages/react-components/react-calendar-preview/library @microsoft/cxe-prg packages/react-components/react-calendar-preview/stories @microsoft/cxe-prg packages/react-components/react-timepicker-preview/library @microsoft/cxe-prg packages/react-components/react-timepicker-preview/stories @microsoft/cxe-prg +packages/react-components/react-datepicker-preview/library @microsoft/cxe-prg +packages/react-components/react-datepicker-preview/stories @microsoft/cxe-prg # <%= NX-CODEOWNER-PLACEHOLDER %> # Deprecated v9 packages - exposed as part of `/unstable` api diff --git a/packages/react-components/react-datepicker-preview/library/.babelrc.json b/packages/react-components/react-datepicker-preview/library/.babelrc.json new file mode 100644 index 0000000000000..630deaf765c49 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/.babelrc.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../.babelrc-v9.json", + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/react-components/react-datepicker-preview/library/.swcrc b/packages/react-components/react-datepicker-preview/library/.swcrc new file mode 100644 index 0000000000000..a5d9d62d2f9c8 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/.swcrc @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "baseUrl": ".", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/react-components/react-datepicker-preview/library/LICENSE b/packages/react-components/react-datepicker-preview/library/LICENSE new file mode 100644 index 0000000000000..a1668d4cc6fdd --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-datepicker-preview + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-datepicker-preview/library/README.md b/packages/react-components/react-datepicker-preview/library/README.md new file mode 100644 index 0000000000000..5a19ad42e6831 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/README.md @@ -0,0 +1,5 @@ +# @fluentui/react-datepicker-preview + +**React Datepicker components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. diff --git a/packages/react-components/react-datepicker-preview/library/config/api-extractor.json b/packages/react-components/react-datepicker-preview/library/config/api-extractor.json new file mode 100644 index 0000000000000..8d482156d10d5 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/config/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json", + "mainEntryPointFilePath": "/../../../../../../dist/out-tsc/types/packages/react-components//library/src/index.d.ts" +} diff --git a/packages/react-components/react-datepicker-preview/library/config/tests.cjs b/packages/react-components/react-datepicker-preview/library/config/tests.cjs new file mode 100644 index 0000000000000..2e211ae9e2142 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/config/tests.cjs @@ -0,0 +1 @@ +/** Jest test setup file. */ diff --git a/packages/react-components/react-datepicker-preview/library/docs/Spec.md b/packages/react-components/react-datepicker-preview/library/docs/Spec.md new file mode 100644 index 0000000000000..200fe8d023840 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/docs/Spec.md @@ -0,0 +1,631 @@ +# @fluentui/react-datepicker-preview Spec + +**Status:** Proposed. This document specifies the target component, not the current scaffold's implemented API. + +## Background + +`DatePicker` combines a date input with a popup calendar. It is the non-compat v9 replacement for +`@fluentui/react-datepicker-compat`, built on `@fluentui/react-calendar-preview`. + +The goal is **the same appearance and user-visible behavior as DatePicker Compat, with a native v9 +component contract**: slots, standard refs, controlled and uncontrolled state, event/data callbacks, +Field integration, token-based Griffel styles, and Calendar Preview's compositional APIs. + +This is not a visual redesign, a native browser date input, or a wrapper around either compat package. +The default experience remains an initially empty, read-only date input with a trailing calendar icon +and a dismissible calendar dialog. Editable input is opt-in. + +This package remains preview-only until the API and parity requirements below are verified. This spec +does not authorize production migration or declare parity achieved. + +## Prior Art + +- [Compat props and slots](../../../react-datepicker-compat/library/src/components/DatePicker/DatePicker.types.ts) +- [Compat behavior](../../../react-datepicker-compat/library/src/components/DatePicker/useDatePicker.tsx) +- [Compat styles](../../../react-datepicker-compat/library/src/components/DatePicker/useDatePickerStyles.styles.ts) +- [Compat unit tests](../../../react-datepicker-compat/library/src/components/DatePicker/DatePicker.test.tsx) + and [browser tests](../../../react-datepicker-compat/library/src/components/DatePicker/DatePicker.cy.tsx) +- [Compat examples](../../../react-datepicker-compat/stories/src/DatePicker) +- [Calendar Preview spec](../../../react-calendar-preview/library/docs/Spec.md) + and [API](../../../react-calendar-preview/library/src/components/Calendar/Calendar.types.ts) +- [Input API](../../../react-input/library/src/components/Input/Input.types.ts) +- [WAI-ARIA date picker dialog example](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) + +When compat comments and implementation disagree, observable behavior and verified examples are the +baseline. In particular, the hook defaults to no opening on focus, Sunday as the first weekday, +`FirstDay` week numbering, and `date.toDateString()` input formatting. Do not infer defaults from the +compat prop comments alone. + +## Sample Code + +Examples use the proposed API; they are not examples of the current scaffold. + +### Basic usage + +```tsx +import { Field } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const Example = () => ( + + + +); +``` + +### Controlled selection and validation + +```tsx +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import { DatePicker, defaultDatePickerErrorStrings } from '@fluentui/react-datepicker-preview'; +import type { DatePickerErrorType } from '@fluentui/react-datepicker-preview'; + +export const EditableExample = () => { + const [value, setValue] = React.useState(null); + const [error, setError] = React.useState(); + + return ( + + setValue(data.value)} + onValidationResult={(_event, data) => setError(data.error)} + /> + + ); +}; +``` + +### Calendar composition + +```tsx + +``` + +For localization, pair input `formatDate` and `parseDate` functions with Calendar's `formatters` +and localized action-slot content. `Intl.DateTimeFormat` formats dates but does not parse user input. +Use `popupSurface={{ 'aria-label': localizedCalendarLabel }}` to localize the dialog name. + +## Variants + +- **Input:** selection-only by default; editable with `allowTextInput`. +- **Appearance and size:** Input's `appearance` and `size` APIs. Defaults are `outline` and `medium`. + `underline` and `filled-lighter` reproduce compat's underlined and borderless treatments. +- **Calendar:** day/month side by side, responsive overlay, explicit overlay, day-only, week numbers, + and optional close and Go to today actions. Configure these through the `calendar` slot. +- **Selection range:** Calendar's `day`, `week`, `workWeek`, and `month` modes are supported through + `calendar.dateRangeType`. The input still displays the activated date, not a formatted range. +- **Popup:** portaled by default; inline when `inlinePopup` is true. Inline means DOM placement, + not an always-visible calendar. +- **State:** empty, selected, hovered, focused, pressed, open, disabled, required, and invalid. + Invalid appearance comes from Field or explicit input accessibility props, not an internal label. + +There is no additional clear button, time picker, multiple-date selection, or start/end-range input. + +## API + +The implementation must replace the scaffold in +[DatePicker.types.ts](../src/components/DatePicker/DatePicker.types.ts) with this contract. + +### Slots and ref + +| Slot | Type | Contract | +| -------------- | ------------------------------------ | ------------------------------------------------------------------------------------- | +| `root` | `NonNullable>` | Required Input component, preserving Input's native-prop routing and styling surface. | +| `calendar` | `NonNullable>` | Required Calendar Preview component; mounted only while the dialog is open. | +| `popupSurface` | `Slot<'div'>` | Dialog surface, rendered by default while open and absent while closed. | + +Input's `input`, `contentBefore`, and `contentAfter` shorthand props remain available through the +root Input contract. They are not duplicate DatePicker-owned slots. The default `contentAfter` is +the calendar icon and its activation affordance. Customizing its content must not remove opening +behavior or accessible trigger semantics. + +The forwarded ref is `HTMLInputElement`, matching Input and compat. The Input wrapper can be reached +through `root.ref`. Calendar's root ref is an `HTMLDivElement`; its picker-slot refs expose the +existing Calendar Preview focus handles. Do not introduce `componentRef`, `ICalendar`, or a second +imperative DatePicker API. + +### Props + +Start from `ComponentProps>`, omitting Input's string `value`, +`defaultValue`, `type`, and `readOnly`. DatePicker fixes `type="text"` and derives `readOnly` from +`allowTextInput`. Preserve other applicable Input props, including `name`, `id`, `placeholder`, +`size`, `appearance`, `disabled`, `required`, `onChange`, and native `aria-*` props. + +| Prop | Type | Default / contract | +| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- | +| `value` | `Date \| null` | Controlled committed date; `undefined` means uncontrolled and `null` means empty. | +| `defaultValue` | `Date \| null` | Initial uncontrolled committed date; defaults to `null`. | +| `onDateChange` | `EventHandler` | Reports a user date commit or clear, not each keystroke. | +| `allowTextInput` | `boolean` | `false`; input is read-only but remains enabled and focusable. | +| `formatDate` | `(date: Date) => string` | `date.toDateString()`; never called with an empty value. | +| `parseDate` | `(text: string) => Date \| null` | Uses `Date.parse`; failure returns `null`. Used only for editable input. | +| `onValidationResult` | `EventHandler` | Reports the result of a user validation attempt. | +| `open` | `boolean` | Controlled popup visibility. | +| `defaultOpen` | `boolean` | `false`; initial uncontrolled visibility. | +| `onOpenChange` | `EventHandler` | Requests a visibility transition. | +| `openOnClick` | `boolean` | `true`; controls opening from the input body, not the icon or keyboard. | +| `openOnFocus` | `boolean` | `false`; opt-in focus opening for selection-only input. | +| `today` | `Date` | Current local date; shared with Calendar. Does not select or populate the input. | +| `minDate`, `maxDate` | `Date` | Inclusive local-calendar-date boundaries for both parsing and Calendar. | +| `inlinePopup` | `boolean` | `false`; renders the positioned surface next to the trigger instead of in a Portal. | +| `mountNode` | `PortalProps['mountNode']` | Portal target; ignored for inline rendering. | +| `positioning` | `PositioningProps` | Below the input wrapper, aligned to logical start, with normal collision handling. | + +`onChange` retains Input's `(event, { value: string })` signature and observes editable draft text. +It does not commit a date. DatePicker owns the displayed string; the nested Input `value`, +`defaultValue`, `type`, and `readOnly` must not independently override DatePicker's state or input mode. +Independent controlled text state is outside this proposal. + +Use `calendar` for Calendar-only configuration: `firstDayOfWeek`, `firstWeekOfYear`, `formatters`, +`layout`, `view`, `defaultView`, `displayedDate`, `defaultDisplayedDate`, `showWeekNumbers`, +`highlightCurrentMonth`, `highlightSelectedMonth`, `allFocusable`, `restrictedDates`, `workWeekDays`, +and picker/action slots. Use Calendar Preview's string unions, not compat enums or interfaces. + +DatePicker owns Calendar's `value`, selection/dismissal wiring, and shared `today`, `minDate`, and +`maxDate`. Omit conflicting `calendar.value`, `calendar.defaultValue`, `calendar.today`, +`calendar.minDate`, and `calendar.maxDate` from the slot's accepted props. Slot event handlers and refs +are composed with internal handlers and refs, not overwritten. A consumer handler runs first; +`preventDefault()` cancels the corresponding built-in action. + +Retain Calendar's default `layout="auto"`, Sunday week start, `firstWeekOfYear="firstDay"`, day-range +selection, visible month picker, visible Go to today action, no close button, no week numbers, and no +month highlights. Do not pass compat-era visibility flags down to Calendar Preview. + +### Events and exports + +Use the repository's `EventHandler` / `EventData` types, with `(event, data)` on every component +callback. Each payload also carries the originating event and its correctly typed event discriminator. +The additional payload fields are: + +```ts +type DatePickerDateChangeData = { + value: Date | null; + selectedDateRange: Date[]; + reason: 'calendar' | 'input' | 'clear'; +}; + +type DatePickerOpenChangeData = { + open: boolean; + reason: 'input' | 'icon' | 'keyboard' | 'focus' | 'selection' | 'dismiss'; +}; + +type DatePickerErrorType = 'invalid-input' | 'out-of-bounds' | 'required-input'; + +type DatePickerValidationResultData = { + error?: DatePickerErrorType; +}; +``` + +These shapes describe payload fields, not complete type declarations. Input commits report +`selectedDateRange: [value]`; clearing reports `[]`; Calendar commits forward Calendar's actual range. +Range generation stays in Calendar, even when a consumer configures week or month selection. +Use `React.ChangeEvent`, `React.FocusEvent`, `React.KeyboardEvent`, or pointer/click events as +appropriate; outside-interaction requests can originate from native events. Do not fabricate a click +event for a blur, scroll, or controlled prop change. + +Each logical action emits at most one date-change request and one open-change request, even when +selection, blur, and dismissal occur together. External prop updates and initial render emit neither. +Controlled callbacks describe a request; they do not imply the owner accepted it. + +Export `DatePicker`, its props/slots/state and event-data types, `DatePickerErrorType`, +`defaultDatePickerErrorStrings`, `datePickerClassNames`, and the standard +`useDatePicker_unstable`, `useDatePickerStyles_unstable`, and `renderDatePicker_unstable` helpers. +The default error messages remain "Invalid date format", "Date is out of bounds", and +"Field is required". Applications can replace them with localized messages. + +## Structure + +### Component implementation + +Use `ForwardRefComponent` and `React.forwardRef`, with the standard state-hook, +style-hook, and render-function split. DatePicker owns input text, parsing, validation, popup +visibility, positioning, dismissal, and popup focus management. + +Calendar Preview owns date arithmetic, range generation, day/month/year navigation, disabled dates, +week numbers, localization of calendar content, grid semantics, selection announcements, and motion. +Do not copy Calendar Compat's implementation or fork Calendar Preview inside DatePicker. + +Compose the existing Input, Portal, positioning, Field-context, utility, and Tabster primitives. +Use `useFluent_unstable().targetDocument` and its `defaultView` for environment access. Opening, +positioning, dismissal, and focus must work in a non-default document as well as during SSR. + +**Dependency review prerequisite:** the requested Calendar Preview composition, and reuse of Input +and Field context, involve component-package dependencies. The repository's +[dependency-layer rules](../../../../../docs/architecture/layers.md) prohibit those edges by default. +Implementation requires an explicitly approved composition exception or an approved foundation-layer +arrangement. This spec records the required composition; it does not silently waive that rule, +authorize copying components, or add dependency changes. + +### DOM + +The conceptual default structure is: + +```html + + + + + + + + + +``` + +The icon's semantic activation target is not an extra Tab stop, matching compat. The input provides +the keyboard route to the same dialog. Expose an accessible name based on the input's label and a +localized action label; preserve voice-control and touch-screen-reader activation. +The dialog and its `aria-controls` relationship exist only while open. Input's `id`, `name`, +description, required state, and invalid state belong on the native input. + +### Styling and visual parity + +Use the compat DatePicker and Calendar rendered together as the visual reference, not just Input. +Match input height, width behavior, padding, typography, icon size and placement, cursor, borders, +focus indicator, surface geometry, calendar spacing, divider, action placement, cell treatments, +and popup alignment in equivalent configurations. + +The popup uses `colorNeutralBackground1`, `colorNeutralForeground1`, `shadow16`, +`borderRadiusMedium`, `strokeWidthThin`, `colorTransparentStroke`, and `typographyStyles.body1`. +Use semantic theme tokens for colors, spacing, typography, and strokes throughout. Inline rendering +must preserve the compat stacking behavior above the positioned input. + +Calendar Preview is not automatically a visual-parity guarantee: for example, its root uses intrinsic +width while [Calendar Compat styles](../../../react-calendar-compat/library/src/components/Calendar/useCalendarStyles.styles.ts) +specify different widths for single/dual pickers and week numbers. Its Go to today spacing also differs. +Record and resolve actual screenshot differences before claiming parity. Address reusable calendar +differences in Calendar Preview, not with DatePicker descendant selectors against private DOM. +Where no existing token reproduces a required measurement, obtain a token/design decision rather +than hardcoding legacy spacing or accepting a silent visual change. + +Apply slot classes through Griffel with consumer `className` last. Preserve the static +`fui-DatePicker`, `fui-DatePicker__calendar`, and `fui-DatePicker__popupSurface` class names. +No new visual adornment or motion is introduced; Calendar Preview motion must respect reduced motion. + +## Migration + +This is an explicit API migration, not a drop-in re-export. + +| Compat API | Proposed API | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `value`, `onSelectDate(date)` | `value`, `onDateChange(event, { value })`; use `null` for empty controlled state. | +| No date `defaultValue` | `defaultValue` initializes uncontrolled selection. | +| `onOpenChange(open)` | `onOpenChange(event, { open, reason })`. | +| `onValidationResult(data)` | `onValidationResult(event, data)`. | +| `underlined` | `appearance="underline"`. | +| `borderless` | `appearance="filled-lighter"`. | +| `disableAutoFocus` | `openOnFocus` with inverted meaning; default remains no focus opening. | +| `parseDateFromString` | `parseDate`. | +| `initialPickerDate` | `calendar.defaultDisplayedDate`; initializes navigation, not the selected input value. | +| `isMonthPickerVisible={false}` | `calendar={{ monthPicker: null }}`. | +| `showMonthPickerAsOverlay` | `calendar={{ layout: 'overlay' }}`. | +| `showGoToToday={false}` | `calendar={{ goToTodayButton: null }}`. | +| `showCloseButton` | `calendar={{ dayPicker: { closeButton: {} } }}`. | +| Week settings, month highlights, `allFocusable` | Corresponding `calendar` props, using string unions rather than compat enums. | +| `strings`, `dateTimeFormatter` | Calendar `formatters` and action-slot content; input formatting/parsing remain separate. | +| `calendar.componentRef` | Standard Calendar root ref or child picker focus handles. | + +Retain `allowTextInput`, `formatDate`, boundaries, `today`, popup options, and Input shorthand +customization. Move Calendar-only settings into the slot; do not retain duplicate legacy aliases. + +V8 consumers must additionally replace `styles`, `theme`, runtime styling, label, and inline error +props with Griffel, FluentProvider, Field, and slot props. V0 has no direct compatibility contract; +map its selected-date and popup state explicitly and revalidate interaction and localization. + +Observable compat defects are not new API guarantees: duplicate close notifications, accepting an +invalid date object, treating the Unix epoch as parse failure, and leaving stale internal selection +after an uncontrolled clear must not be reproduced. Cover these corrections explicitly in tests. +Any other discovered user-visible difference requires a documented decision and regression coverage, +not an undocumented "v9 improvement". + +### Compat-to-preview migration walkthrough + +This walkthrough targets `@fluentui/react-datepicker-compat` consumers. The destination code uses +the **proposed** API in this spec and is not runnable against the current scaffold. Migrate only +after that API is implemented and parity is verified; keep production consumers on compat meanwhile. + +#### 1. Replace imports and adapt controlled callbacks + +Before: + +```tsx +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import { DayOfWeek, FirstWeekOfYear } from '@fluentui/react-calendar-compat'; +import { DatePicker, defaultDatePickerErrorStrings } from '@fluentui/react-datepicker-compat'; +import type { DatePickerValidationResultData } from '@fluentui/react-datepicker-compat'; + +export const Example = () => { + const [value, setValue] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [error, setError] = React.useState(); + + return ( + + setValue(date ?? null)} + open={open} + onOpenChange={setOpen} + onValidationResult={data => setError(data.error)} + allowTextInput + underlined + firstDayOfWeek={DayOfWeek.Monday} + firstWeekOfYear={FirstWeekOfYear.FirstFourDayWeek} + showWeekNumbers + showMonthPickerAsOverlay + showGoToToday={false} + showCloseButton + /> + + ); +}; +``` + +After: + +```tsx +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import { DatePicker, defaultDatePickerErrorStrings } from '@fluentui/react-datepicker-preview'; +import type { DatePickerErrorType } from '@fluentui/react-datepicker-preview'; + +export const Example = () => { + const [value, setValue] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [error, setError] = React.useState(); + + return ( + + setValue(data.value)} + open={open} + onOpenChange={(_event, data) => setOpen(data.open)} + onValidationResult={(_event, data) => setError(data.error)} + allowTextInput + appearance="underline" + calendar={{ + firstDayOfWeek: 'monday', + firstWeekOfYear: 'firstFourDayWeek', + showWeekNumbers: true, + layout: 'overlay', + goToTodayButton: null, + dayPicker: { closeButton: { 'aria-label': 'Close date picker' } }, + }} + /> + + ); +}; +``` + +Do not pass a state setter directly to an event/data callback: its first argument is the event. +Use `data.value`, `data.open`, or `data.error`. Update callback type imports and tests that previously +asserted a date-only or boolean-only call. Application side effects must not depend on duplicate +compat close notifications. + +#### 2. Separate selection, navigation, and draft text + +- Preserve `value={null}` for an empty controlled field. Do not replace it with `undefined`, which + selects uncontrolled mode. If the application does not need controlled selection, replace its + initial-value state plumbing with `defaultValue={initialDate}`. +- Move `initialPickerDate` to `calendar.defaultDisplayedDate`, not `defaultValue`. An initial + navigation target must not populate the field. Use `calendar.displayedDate` and + `calendar.onDisplayedDateChange` only when navigation itself needs to be controlled. +- Move date commits from compat `onSelectDate` to `onDateChange`. Retain `onChange` only for observing + raw input text; it still receives a string and must not be used as a date-selection callback. +- Rename `parseDateFromString` to `parseDate`. Keep the existing parser and formatter paired; + do not assume that switching Calendar localization also changes input parsing. +- Keep `minDate`, `maxDate`, and `today` at DatePicker level. Remove duplicate values from the + `calendar` slot so input validation and calendar availability share one source of truth. + +For example, browsing an initial month while leaving the input empty becomes: + +```tsx + +``` + +#### 3. Move Calendar customization without losing existing slot props + +Merge migrated settings into the consumer's existing `calendar` object, including nested +`dayPicker` and `monthPicker` objects. Do not replace an existing slot object and inadvertently drop +its handlers, refs, labels, or custom content. + +- Replace compat `DayOfWeek`, `FirstWeekOfYear`, and `DateRangeType` enum values with Calendar + Preview's string unions. For example, `DateRangeType.WorkWeek` becomes + `calendar={{ dateRangeType: 'workWeek' }}`. +- Move range, restricted-date, highlighting, and week-number configuration to Calendar Preview's + corresponding props. Read `data.selectedDateRange` from `onDateChange` when the application needs + the generated range; the input continues to display only `data.value`. +- Translate `strings` and `dateTimeFormatter` explicitly into Calendar `formatters` and action-slot + content. There is no one-to-one object spread between compat's strings and the new formatter API. + Include navigation labels, selection/today labels, week labels, Go to today content, and close labels. +- Preserve the popup's localized accessible name through `popupSurface['aria-label']` and preserve + Field label, description, required state, and error-message wiring. +- Keep `inlinePopup`, `mountNode`, `positioning`, and applicable Input props. Replace legacy appearance + flags rather than supplying both flags and `appearance`. +- Replace `disableAutoFocus` with `openOnFocus={!disableAutoFocus}` only when the old flag was + explicitly configured. When it was omitted, omit `openOnFocus` to retain the default. Set + `openOnClick` independently; do not rely on compat's coupling between click and focus flags. +- Keep input refs as `React.Ref`. Replace Calendar's `componentRef` only where + imperative access is actually needed, using the preview root ref or a picker focus handle. + +#### 4. Verify the migrated consumer + +Check the consumer's existing compat configuration against the acceptance matrix below, not only +the default story. Compare identical values, dates, locale, theme, dimensions, and viewport. +Specifically verify opening and return focus, editable validation, required clearing, range +selection, localization, and controlled-owner acceptance/rejection. + +Remove compat imports and direct dependencies only when no remaining consumer needs them. Do not +remove a shared dependency as part of migrating one DatePicker. A successful type check alone does +not establish visual or behavioral parity. + +## Behaviors + +### State and date model + +- Maintain committed date, editable draft string, and popup visibility separately. Browsing a month + or year, changing the displayed date, and using Go to today do not populate or change the input. +- Uncontrolled selection starts from `defaultValue ?? null`, never implicitly from today. Pass + `value={null}` to Calendar when empty so its uncontrolled today default cannot select a date. +- The initial displayed date resolves from `calendar.defaultDisplayedDate`, the selected date, + then the current local date. `calendar.displayedDate` controls navigation independently. + Reopening with a selected date reveals it unless navigation is explicitly controlled; reopening + empty uses the initial displayed-date rule. Bound navigation without committing a boundary date. +- `value` and `open` are independently controlled when defined; do not switch either between controlled + and uncontrolled during the component's lifetime. Use `useControllableState`. +- An external `value` update reformats the input without interaction callbacks. A formatter change + updates committed-date text. Do not overwrite a user's draft on unrelated rerenders. + A controlled owner that rejects a commit remains authoritative. +- Compare boundaries and restricted dates by local year/month/day, not timestamps. Do not convert + date-only values through UTC, mutate consumer dates, or change days around daylight-saving transitions. +- Out-of-range supplied values must not be silently clamped into a new committed value. Calendar + cannot select them; validation reports the problem on the next user validation attempt. +- `disabled` prevents editing, activation, and a usable popup, including with `open={true}`. Disabling + an already-open picker removes the popup without committing a draft or emitting a synthetic user event. + +### Opening and dismissal + +- Clicking/tapping the input opens the calendar when `openOnClick` is true. Clicking the icon opens + it independently. In editable mode, activating the already-open input or icon dismisses it for editing; + in selection-only mode, repeated trigger activation leaves it open. +- Input focus alone does not open by default. `openOnFocus` applies only to selection-only mode. + Focus restoration after dismissal must never immediately reopen the popup. +- A committed Calendar selection updates or requests the date, formats the input, validates, and + closes or requests closure. Preserve `selectedDateRange` for non-day Calendar modes. +- Escape, Calendar's close action, an outside pointer interaction, or scrolling outside the + trigger/surface dismisses the popup. Scrolling within the popup must not dismiss it. +- A dismissal validates editable text using the same rules as blur. Escape does not introduce a + new rollback-to-open-time-value behavior. Navigation alone never emits `onDateChange`. +- Editing text while the popup is open closes it and retains the new draft. Do not parse every + keystroke or accidentally commit the old draft as part of that edit. +- Closing unmounts the surface and Calendar. Opening focuses the selected/navigated day, or an + available bounded fallback. If no day is selectable, focus an enabled navigation/close action or + the dialog itself. Never leave focus in unmounted content. + +### Editing, commits, and validation + +Validate on input blur, Enter, and popup dismissal; coalesce overlapping events from one action. +Do not reparse unchanged text that already equals `formatDate(committedDate)`, since a custom format +may be intentionally ambiguous. Nevertheless, validate required state and availability. + +| Input at validation | Result | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Valid parse, in bounds and not restricted | Commit/request the date and normalize the displayed text with `formatDate`. | +| Empty text, optional | Commit/request `null`, display empty text, and report no error. | +| Empty text, required | Commit/request `null` and report `required-input`; do not resurrect the previous selection. | +| Parse failure or invalid `Date` | Report `invalid-input`, retain the committed date, and restore its formatted text (or empty text), matching compat's invalid-format treatment. | +| Parsed date outside boundaries or in `calendar.restrictedDates` | Report `out-of-bounds`, retain the committed date, and leave the draft visible for correction. | +| Selection-only input | Validate required state and committed-date availability without parsing. | + +Use finite timestamp checks, not truthiness, for default parsing. Empty handling happens before +`parseDate`; `null` or an invalid `Date` result means invalid input. A consumer parser's thrown +exception is not silently converted into success or an empty value. + +Field's required state takes precedence as in compat. Use Field context to inherit input ID, +label/description relationships, invalid state, and validation presentation. DatePicker reports +validation but does not render an error message or automatically choose a localized message for Field. +The default `type="text"` submits the displayed string under `name`; do not add a hidden ISO-value input. + +### Keyboard, pointer, and touch + +| Focus area | Input | Behavior | +| -------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- | +| Closed input | `Enter` | Validate and request opening; consume the key rather than submitting the form. | +| Closed input | `ArrowDown` | Request opening and move focus into Calendar. | +| Open editable input | `Enter` | Validate and request dismissal. | +| Open picker | `Escape` | Dismiss, consume the key, and restore input focus. | +| Closed input | `Escape` | Leave the event available to the surrounding UI. | +| Selection-only input | Text-editing keys | Do not edit the text; retain ordinary focus and selection behavior. | +| Editable input | Text-editing keys | Preserve native editing, including caret movement and clipboard actions. | +| Calendar grids | Arrows, paging keys, `Enter`, `Space` | Delegate to Calendar Preview's documented day/month/year navigation and activation. | +| Open dialog | `Tab`, `Shift+Tab` | Cycle through enabled dialog controls using its focus trap. | + +Input and icon share the same mouse, touch, voice-control, and assistive-technology opening path. +Use pointer cursor treatment on the selection affordance, ordinary editing behavior where applicable, +and default cursors for disabled content. Unavailable dates can be focused with `allFocusable` but +cannot be selected. Consumer-canceled keyboard or activation events must not trigger internal actions. + +## Accessibility + +- Use a native text input, not `input[type=date]`: browser-native date controls cannot guarantee the + specified styling, parsing, Calendar Preview composition, or interaction parity. +- The input exposes `role="combobox"`, `aria-haspopup="dialog"`, current `aria-expanded`, and an + `aria-controls` reference while open. Read-only mode must not be exposed as disabled. +- The popup is a named `role="dialog"` with default name "Calendar". Match compat's modal focus + treatment using Tabster; `aria-modal="true"` is valid only while focus containment and assistive + technology isolation are actually active. Calendar itself is not a modal and must not own the trap. +- Opening transfers focus into Calendar using merged picker handles, not compat `componentRef`, + global document access, CSS-class queries, or an arbitrary timeout. +- Selection, Escape, and the close action return focus to the input without reopening. Outside + pointer dismissal must not steal focus from the outside control the user activated. If the picker + is disabled or unmounted, do not attempt to restore focus to it. +- Calendar owns roving grid focus, selected/current/unavailable semantics, view-switch focus, + localized grid names, navigation announcements, and selection announcements. Do not add duplicate + DatePicker live-region announcements. Field owns error-message association and presentation. +- Localize dialog/trigger/close names, Calendar labels, Go to today content, input formatting/parsing, + and validation messages independently. Locale formatting does not change Gregorian arithmetic. +- Support RTL, keyboard-only operation, touch screen readers, voice control, forced colors, and + reduced motion. Keep selected, current, disabled, and focused states distinguishable without color alone. +- At 400% zoom and narrow viewports, keep all controls usable through Calendar's overlay layout and + popup collision handling. Test both inline and portaled rendering with VoiceOver, NVDA, and JAWS. + +## Acceptance Criteria and Verification + +Parity is a release requirement, not an assumption based on reusing Calendar Preview. + +| Area | Required evidence | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default configuration | Initially empty read-only input; click/Enter/ArrowDown opening; no default focus opening; matching popup and default Calendar configuration. | +| Visual parity | Paired compat/preview fixtures for default, editable, disabled, focused, invalid, all matching appearances/sizes, day-only, side-by-side, overlay, week numbers, month/year views, and range states. | +| Visual environments | Light, dark, brand theme, RTL, forced colors, narrow/wide viewport, zoom, reduced motion, inline/portal, and custom mount node. | +| Selection | Controlled and uncontrolled select, clear, repeat selection, same-date activation, external update, rejected controlled request, and every supported Calendar range mode. | +| Editing | Draft changes versus date commits; blur/Enter/dismiss validation; ambiguous formatter round trips; invalid/empty/out-of-bounds/restricted dates; no duplicate callbacks. | +| Date arithmetic | Inclusive boundaries, month/year rollover, leap day, non-midnight values, Unix epoch, and time zones with daylight-saving transitions. | +| Popup | Every open/dismiss path, controlled requests, focus entry/return, outside-target focus, internal versus external scroll, default-open, disabling while open, and no reopen loop. | +| Composition | Input native props, forwarded ref, nested refs, slot handler ordering/cancellation, consumer classes last, Calendar display/view control, and Field required/invalid/description wiring. | +| Accessibility | Combobox/dialog relationships, localized names, grid announcements, focus trapping, keyboard navigation, disabled-but-focusable dates, and screen-reader checks. | +| Environment | SSR/hydration without global DOM access; correct document, portal, focus, and outside-interaction handling in an iframe. | + +Use frozen `today`, explicit dates/locales/time zones, equal container widths, and settled motion in +paired fixtures. Compare matching states rather than unrelated default screenshots. Require zero +unexplained screenshot differences under the same rendering environment; any approved rasterization +tolerance must not hide geometry, token, or interaction differences. Test actual popup and grid +measurements as well as screenshots. + +Port the compat stories and behavioral assertions to the new API, retaining the originals as the +reference. Add focused unit tests for state/events/validation, browser tests for actual focus and +outside interactions, v9 conformance tests, and visual-regression fixtures for the matrix above. +API extraction and type tests must verify slot restrictions, event/data signatures, string unions, +and ref targets. Run relevant Nx targets for the new package and any Calendar Preview changes. + +Implementation is ready for review only when the dependency arrangement is approved, every compat +configuration has a migration path, paired visual/behavioral evidence is recorded, and any remaining +difference has an explicit decision. Until then, retain the preview designation and do not claim the +component is an exact replacement. diff --git a/packages/react-components/react-datepicker-preview/library/eslint.config.cjs b/packages/react-components/react-datepicker-preview/library/eslint.config.cjs new file mode 100644 index 0000000000000..ec2e7cb1fc479 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/eslint.config.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const fluentPlugin = require('@fluentui/eslint-plugin'); + +module.exports = [...fluentPlugin.configs['flat/react']]; diff --git a/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md b/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md new file mode 100644 index 0000000000000..e2b71f8801117 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md @@ -0,0 +1,38 @@ +## API Report File for "@fluentui/react-datepicker-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type * as React_2 from 'react'; +import type { Slot } from '@fluentui/react-utilities'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +// @public +export const DatePicker: ForwardRefComponent; + +// @public (undocumented) +export const datePickerClassNames: SlotClassNames; + +// @public +export type DatePickerProps = ComponentProps & {}; + +// @public +export type DatePickerState = ComponentState; + +// @public +export const renderDatePicker_unstable: (state: DatePickerState) => JSXElement; + +// @public +export const useDatePicker_unstable: (props: DatePickerProps, ref: React_2.Ref) => DatePickerState; + +// @public +export const useDatePickerStyles_unstable: (state: DatePickerState) => DatePickerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-datepicker-preview/library/jest.config.cjs b/packages/react-components/react-datepicker-preview/library/jest.config.cjs new file mode 100644 index 0000000000000..7dabe1f81b2be --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/jest.config.cjs @@ -0,0 +1,34 @@ +// @ts-check +/* eslint-disable */ + +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse(readFileSync(join(__dirname, '.swcrc'), 'utf-8')); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'react-datepicker-preview', + preset: '../../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', swcJestConfig], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.cjs'], + snapshotSerializers: ['@griffel/jest-serializer'], +}; diff --git a/packages/react-components/react-datepicker-preview/library/package.json b/packages/react-components/react-datepicker-preview/library/package.json new file mode 100644 index 0000000000000..fa428df69af76 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/package.json @@ -0,0 +1,59 @@ +{ + "name": "@fluentui/react-datepicker-preview", + "version": "0.0.0", + "private": true, + "description": "New fluentui react package", + "type": "module", + "main": "lib-commonjs/index.cjs", + "module": "lib/index.js", + "typings": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "*.md", + "dist/*.d.ts", + "dist/*.d.cts", + "lib", + "lib-commonjs" + ], + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.6", + "@fluentui/react-shared-contexts": "^9.26.4", + "@fluentui/react-theme": "^9.2.2", + "@fluentui/react-utilities": "^9.26.7", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./lib/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./lib-commonjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + } +} diff --git a/packages/react-components/react-datepicker-preview/library/project.json b/packages/react-components/react-datepicker-preview/library/project.json new file mode 100644 index 0000000000000..3dab67782c857 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/project.json @@ -0,0 +1,8 @@ +{ + "name": "react-datepicker-preview", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/react-components/react-datepicker-preview/library/src", + "tags": ["platform:web", "vNext"], + "implicitDependencies": [] +} diff --git a/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts b/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts new file mode 100644 index 0000000000000..bd248a994b0e0 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts @@ -0,0 +1,8 @@ +export { + datePickerClassNames, + DatePicker, + renderDatePicker_unstable, + useDatePicker_unstable, + useDatePickerStyles_unstable, +} from './components/DatePicker/index'; +export type { DatePickerProps, DatePickerState } from './components/DatePicker/index'; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 0000000000000..60bcff06a3cd9 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { DatePicker } from './DatePicker'; + +describe('DatePicker', () => { + isConformant({ + Component: DatePicker, + displayName: 'DatePicker', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default DatePicker); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000000000..e283a2da05fbc --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,30 @@ +'use client'; +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useDatePicker_unstable } from './useDatePicker'; +import { renderDatePicker_unstable } from './renderDatePicker'; +import { useDatePickerStyles_unstable } from './useDatePickerStyles.styles'; +import type { DatePickerProps } from './DatePicker.types'; + +/** + * DatePicker component - TODO: add more docs + */ +export const DatePicker: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useDatePicker_unstable(props, ref); + + useDatePickerStyles_unstable(state); + + /** + * @see https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/react-components/convergence/custom-styling.md + * + * TODO: 💡 once package will become stable (PR which will be part of promoting PREVIEW package to STABLE), + * - uncomment this line + * - update types {@link file://./../../../../../../../packages/react-components/react-shared-contexts/library/src/CustomStyleHooksContext/CustomStyleHooksContext.ts#CustomStyleHooksContextValue} + * - verify that custom global style override works for your component + */ + // useCustomStyleHook_unstable('useDatePickerStyles_unstable')(state); + + return renderDatePicker_unstable(state); +}); + +DatePicker.displayName = 'DatePicker'; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts new file mode 100644 index 0000000000000..a140322c30934 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts @@ -0,0 +1,17 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type DatePickerSlots = { + root: Slot<'div'>; +}; + +/** + * DatePicker Props + */ +export type DatePickerProps = ComponentProps & {}; + +/** + * State used in rendering DatePicker + */ +export type DatePickerState = ComponentState; +// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from DatePickerProps. +// & Required> diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap new file mode 100644 index 0000000000000..e0db7d1cbf9a4 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`DatePicker renders a default state 1`] = ` +
+
+ Default DatePicker +
+
+`; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts new file mode 100644 index 0000000000000..11adf7404e60d --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts @@ -0,0 +1,5 @@ +export { DatePicker } from './DatePicker'; +export type { DatePickerProps, DatePickerState } from './DatePicker.types'; +export { renderDatePicker_unstable } from './renderDatePicker'; +export { useDatePicker_unstable } from './useDatePicker'; +export { datePickerClassNames, useDatePickerStyles_unstable } from './useDatePickerStyles.styles'; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx new file mode 100644 index 0000000000000..24b75a9f1b1ce --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx @@ -0,0 +1,16 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { DatePickerState, DatePickerSlots } from './DatePicker.types'; + +/** + * Render the final JSX of DatePicker + */ +export const renderDatePicker_unstable = (state: DatePickerState): JSXElement => { + assertSlots(state); + + // TODO Add additional slots in the appropriate place + return ; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts new file mode 100644 index 0000000000000..9c5851e42033e --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts @@ -0,0 +1,31 @@ +import type * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { DatePickerProps, DatePickerState } from './DatePicker.types'; + +/** + * Create the state required to render DatePicker. + * + * The returned state can be modified with hooks such as useDatePickerStyles_unstable, + * before being passed to renderDatePicker_unstable. + * + * @param props - props from this instance of DatePicker + * @param ref - reference to root HTMLDivElement of DatePicker + */ +export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref): DatePickerState => { + return { + // TODO add appropriate props/defaults + components: { + // TODO add each slot's element type or component + root: 'div', + }, + // TODO add appropriate slots, for example: + // mySlot: resolveShorthand(props.mySlot), + root: slot.always( + getIntrinsicElementProps('div', { + ref, + ...props, + }), + { elementType: 'div' }, + ), + }; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts new file mode 100644 index 0000000000000..3bdfdbb8db3f5 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts @@ -0,0 +1,37 @@ +'use client'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { DatePickerSlots, DatePickerState } from './DatePicker.types'; + +export const datePickerClassNames: SlotClassNames = { + root: 'fui-DatePicker', + // TODO: add class names for all slots on DatePickerSlots. + // Should be of the form `: 'fui-DatePicker__` +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + // TODO Add default styles for the root element + }, + + // TODO add additional classes for different states and/or slots +}); + +/** + * Apply styling to the DatePicker slots based on the state + */ +export const useDatePickerStyles_unstable = (state: DatePickerState): DatePickerState => { + 'use no memo'; + + const styles = useStyles(); + // eslint-disable-next-line react-hooks/immutability + state.root.className = mergeClasses(datePickerClassNames.root, styles.root, state.root.className); + + // TODO Add class names to slots, for example: + // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + + return state; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/index.ts b/packages/react-components/react-datepicker-preview/library/src/index.ts new file mode 100644 index 0000000000000..ae4771023b1f0 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/index.ts @@ -0,0 +1,8 @@ +export { + datePickerClassNames, + DatePicker, + renderDatePicker_unstable, + useDatePicker_unstable, + useDatePickerStyles_unstable, +} from './DatePicker'; +export type { DatePickerProps, DatePickerState } from './DatePicker'; diff --git a/packages/react-components/react-datepicker-preview/library/src/testing/isConformant.ts b/packages/react-components/react-datepicker-preview/library/src/testing/isConformant.ts new file mode 100644 index 0000000000000..8ed2da0f92513 --- /dev/null +++ b/packages/react-components/react-datepicker-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-datepicker-preview/library/tsconfig.json b/packages/react-components/react-datepicker-preview/library/tsconfig.json new file mode 100644 index 0000000000000..32bdbdf1ac26f --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json b/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json new file mode 100644 index 0000000000000..53066fdd11fff --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "dom"], + "declaration": true, + "declarationDir": "../../../../dist/out-tsc/types", + "outDir": "../../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment"] + }, + "exclude": [ + "./src/testing/**", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx" + ], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/react-components/react-datepicker-preview/library/tsconfig.spec.json b/packages/react-components/react-datepicker-preview/library/tsconfig.spec.json new file mode 100644 index 0000000000000..911456fe4b4d9 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", + "./src/testing/**/*.ts", + "./src/testing/**/*.tsx" + ] +} diff --git a/packages/react-components/react-datepicker-preview/stories/.storybook/main.cjs b/packages/react-components/react-datepicker-preview/stories/.storybook/main.cjs new file mode 100644 index 0000000000000..67905c6bfe15f --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/.storybook/main.cjs @@ -0,0 +1,14 @@ +const rootMain = require('../../../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, +}); diff --git a/packages/react-components/react-datepicker-preview/stories/.storybook/preview.js b/packages/react-components/react-datepicker-preview/stories/.storybook/preview.js new file mode 100644 index 0000000000000..98274ed0b8095 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/.storybook/preview.js @@ -0,0 +1,9 @@ +import * as rootPreview from '../../../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; + +export const tags = ['autodocs']; diff --git a/packages/react-components/react-datepicker-preview/stories/.storybook/tsconfig.json b/packages/react-components/react-datepicker-preview/stories/.storybook/tsconfig.json new file mode 100644 index 0000000000000..434150d0bd347 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment"] + }, + "include": ["*.js", "*.cjs"] +} diff --git a/packages/react-components/react-datepicker-preview/stories/README.md b/packages/react-components/react-datepicker-preview/stories/README.md new file mode 100644 index 0000000000000..75ff094e1128b --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/README.md @@ -0,0 +1,17 @@ +# @fluentui/react-datepicker-preview-stories + +Storybook stories for packages/react-components/react-datepicker-preview + +## Usage + +To include within storybook specify stories globs: + +\`\`\`js +module.exports = { +stories: ['../packages/react-components/react-datepicker-preview/stories/src/**/*.mdx', '../packages/react-components/react-datepicker-preview/stories/src/**/index.stories.@(ts|tsx)'], +} +\`\`\` + +## API + +no public API available diff --git a/packages/react-components/react-datepicker-preview/stories/eslint.config.js b/packages/react-components/react-datepicker-preview/stories/eslint.config.js new file mode 100644 index 0000000000000..f8362c3e41303 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check + +const fluentPlugin = require('@fluentui/eslint-plugin'); + +module.exports = [ + ...fluentPlugin.configs['flat/react'], + { + rules: {}, + }, +]; diff --git a/packages/react-components/react-datepicker-preview/stories/package.json b/packages/react-components/react-datepicker-preview/stories/package.json new file mode 100644 index 0000000000000..85c8e4784fc0a --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fluentui/react-datepicker-preview-stories", + "version": "0.0.0", + "private": true +} diff --git a/packages/react-components/react-datepicker-preview/stories/project.json b/packages/react-components/react-datepicker-preview/stories/project.json new file mode 100644 index 0000000000000..d36b023a0f9e4 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/project.json @@ -0,0 +1,8 @@ +{ + "name": "react-datepicker-preview-stories", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/react-components/react-datepicker-preview/stories/src", + "tags": ["vNext", "platform:web", "type:stories"], + "implicitDependencies": [] +} diff --git a/packages/react-components/react-datepicker-preview/stories/src/.gitkeep b/packages/react-components/react-datepicker-preview/stories/src/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md new file mode 100644 index 0000000000000..08ff8ddeeb5f8 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md @@ -0,0 +1,5 @@ +## Best practices + +### Do + +### Don't diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx new file mode 100644 index 0000000000000..3e3c9b0214ebb --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; +import type { DatePickerProps } from '@fluentui/react-datepicker-preview'; + +export const Default = (props: Partial): React.ReactElement => ; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx new file mode 100644 index 0000000000000..a66fea00edb80 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx @@ -0,0 +1,18 @@ +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +import descriptionMd from './DatePickerDescription.md'; +import bestPracticesMd from './DatePickerBestPractices.md'; + +export { Default } from './DatePickerDefault.stories'; + +export default { + title: 'Preview Components/DatePicker', + component: DatePicker, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/index.ts b/packages/react-components/react-datepicker-preview/stories/src/index.ts new file mode 100644 index 0000000000000..cb0ff5c3b541f --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/react-components/react-datepicker-preview/stories/tsconfig.json b/packages/react-components/react-datepicker-preview/stories/tsconfig.json new file mode 100644 index 0000000000000..efc50169d1df1 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ] +} diff --git a/packages/react-components/react-datepicker-preview/stories/tsconfig.lib.json b/packages/react-components/react-datepicker-preview/stories/tsconfig.lib.json new file mode 100644 index 0000000000000..9486b224643d9 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2019", "dom"], + "outDir": "../../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment"] + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/tsconfig.base.all.json b/tsconfig.base.all.json index 9b12a61a3ea04..d344fb35d55a1 100644 --- a/tsconfig.base.all.json +++ b/tsconfig.base.all.json @@ -118,6 +118,7 @@ "@fluentui/react-datepicker-compat-stories": [ "packages/react-components/react-datepicker-compat/stories/src/index.ts" ], + "@fluentui/react-datepicker-preview": ["packages/react-components/react-datepicker-preview/library/src/index.ts"], "@fluentui/react-dialog": ["packages/react-components/react-dialog/library/src/index.ts"], "@fluentui/react-dialog-stories": ["packages/react-components/react-dialog/stories/src/index.ts"], "@fluentui/react-divider": ["packages/react-components/react-divider/library/src/index.ts"], @@ -250,6 +251,9 @@ "packages/react-components/react-timepicker-compat/stories/src/index.ts" ], "@fluentui/react-timepicker-preview": ["packages/react-components/react-timepicker-preview/library/src/index.ts"], + "@fluentui/react-timepicker-preview-stories": [ + "packages/react-components/react-timepicker-preview/stories/src/index.ts" + ], "@fluentui/react-toast": ["packages/react-components/react-toast/library/src/index.ts"], "@fluentui/react-toast-stories": ["packages/react-components/react-toast/stories/src/index.ts"], "@fluentui/react-toolbar": ["packages/react-components/react-toolbar/library/src/index.ts"], @@ -270,8 +274,8 @@ "@fluentui/visual-regression-assert": ["tools/visual-regression-assert/src/index.ts"], "@fluentui/visual-regression-utilities": ["tools/visual-regression-utilities/src/index.ts"], "@fluentui/workspace-plugin": ["tools/workspace-plugin/src/index.ts"], - "@fluentui/react-timepicker-preview-stories": [ - "packages/react-components/react-timepicker-preview/stories/src/index.ts" + "@fluentui/react-datepicker-preview-stories": [ + "packages/react-components/react-datepicker-preview/stories/src/index.ts" ] } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 65e2c02a02686..e687140377cb4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -73,6 +73,10 @@ "@fluentui/react-datepicker-compat-stories": [ "packages/react-components/react-datepicker-compat/stories/src/index.ts" ], + "@fluentui/react-datepicker-preview": ["packages/react-components/react-datepicker-preview/library/src/index.ts"], + "@fluentui/react-datepicker-preview-stories": [ + "packages/react-components/react-datepicker-preview/stories/src/index.ts" + ], "@fluentui/react-dialog": ["packages/react-components/react-dialog/library/src/index.ts"], "@fluentui/react-dialog-stories": ["packages/react-components/react-dialog/stories/src/index.ts"], "@fluentui/react-divider": ["packages/react-components/react-divider/library/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index afafb44c1570d..a817831847652 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4148,6 +4148,31 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/react-datepicker-preview-stories@workspace:packages/react-components/react-datepicker-preview/stories": + version: 0.0.0-use.local + resolution: "@fluentui/react-datepicker-preview-stories@workspace:packages/react-components/react-datepicker-preview/stories" + languageName: unknown + linkType: soft + +"@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library": + version: 0.0.0-use.local + resolution: "@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library" + dependencies: + "@fluentui/react-jsx-runtime": "npm:^9.4.6" + "@fluentui/react-shared-contexts": "npm:^9.26.4" + "@fluentui/react-theme": "npm:^9.2.2" + "@fluentui/react-utilities": "npm:^9.26.7" + "@griffel/react": "npm:^1.5.32" + "@swc/helpers": "npm:^0.5.1" + peerDependencies: + "@types/react": ">=16.14.0 <20.0.0" + react: ">=16.14.0 <20.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@fluentui/react-dialog-stories@workspace:packages/react-components/react-dialog/stories": version: 0.0.0-use.local resolution: "@fluentui/react-dialog-stories@workspace:packages/react-components/react-dialog/stories" From dc21d6e43bd78474cff4a3709d363751bdc96fdf Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 17 Oct 2026 15:07:00 +0200 Subject: [PATCH 02/11] docs(react-datepicker-preview): remove composition review prerequisite Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-datepicker-preview/library/docs/Spec.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/react-components/react-datepicker-preview/library/docs/Spec.md b/packages/react-components/react-datepicker-preview/library/docs/Spec.md index 200fe8d023840..679e4151df123 100644 --- a/packages/react-components/react-datepicker-preview/library/docs/Spec.md +++ b/packages/react-components/react-datepicker-preview/library/docs/Spec.md @@ -245,13 +245,6 @@ Compose the existing Input, Portal, positioning, Field-context, utility, and Tab Use `useFluent_unstable().targetDocument` and its `defaultView` for environment access. Opening, positioning, dismissal, and focus must work in a non-default document as well as during SSR. -**Dependency review prerequisite:** the requested Calendar Preview composition, and reuse of Input -and Field context, involve component-package dependencies. The repository's -[dependency-layer rules](../../../../../docs/architecture/layers.md) prohibit those edges by default. -Implementation requires an explicitly approved composition exception or an approved foundation-layer -arrangement. This spec records the required composition; it does not silently waive that rule, -authorize copying components, or add dependency changes. - ### DOM The conceptual default structure is: @@ -625,7 +618,7 @@ outside interactions, v9 conformance tests, and visual-regression fixtures for t API extraction and type tests must verify slot restrictions, event/data signatures, string unions, and ref targets. Run relevant Nx targets for the new package and any Calendar Preview changes. -Implementation is ready for review only when the dependency arrangement is approved, every compat -configuration has a migration path, paired visual/behavioral evidence is recorded, and any remaining +Implementation is ready for review only when every compat configuration has a migration path, +paired visual/behavioral evidence is recorded, and any remaining difference has an explicit decision. Until then, retain the preview designation and do not claim the component is an exact replacement. From b4de64616c62145b39b5b1f12e4f80e42719e570 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 17 Oct 2026 15:10:35 +0200 Subject: [PATCH 03/11] feat(react-datepicker-preview): add shared date-input state and validation Implement internal controlled selection, draft editing, parsing, validation, and Calendar range forwarding for subsequent styled and headless integration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../library/README.md | 8 + .../DatePicker/useDatePickerDateState.test.ts | 370 ++++++++++++++++++ .../DatePicker/useDatePickerDateState.ts | 176 +++++++++ 3 files changed, 554 insertions(+) create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.test.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts diff --git a/packages/react-components/react-datepicker-preview/library/README.md b/packages/react-components/react-datepicker-preview/library/README.md index 5a19ad42e6831..2c79a9cc8bb26 100644 --- a/packages/react-components/react-datepicker-preview/library/README.md +++ b/packages/react-components/react-datepicker-preview/library/README.md @@ -3,3 +3,11 @@ **React Datepicker components for [Fluent UI React](https://react.fluentui.dev/)** These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + +The exported DatePicker is currently a scaffold, not a usable date input. The proposed API and parity +requirements are described in the [spec](./docs/Spec.md). + +The internal `useDatePickerDateState` hook implements selection, editable draft text, parsing, and +validation for reuse by the styled and headless integrations. It is not yet connected to the scaffold +or exported as a supported API. Calendar availability is supplied by the integration so the hook does +not duplicate Calendar's date arithmetic or range generation. diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.test.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.test.ts new file mode 100644 index 0000000000000..c108913ea6af0 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.test.ts @@ -0,0 +1,370 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDatePickerDateState } from './useDatePickerDateState'; +import type { DatePickerDateStateOptions } from './useDatePickerDateState'; + +describe('useDatePickerDateState', () => { + const selectedDate = new Date(2030, 0, 15); + const nextDate = new Date(2030, 0, 20); + const event = new Event('blur'); + + it('starts empty without formatting or parsing a value', () => { + const formatDate = jest.fn(); + const parseDate = jest.fn(); + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ formatDate, parseDate, onDateChange, onValidationResult }), + ); + expect(result.current.value).toBeNull(); + expect(result.current.text).toBe(''); + expect(formatDate).not.toHaveBeenCalled(); + expect(parseDate).not.toHaveBeenCalled(); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).not.toHaveBeenCalled(); + }); + + it('initializes uncontrolled selection without reacting to subsequent default changes', () => { + const { result, rerender } = renderHook((options: DatePickerDateStateOptions) => useDatePickerDateState(options), { + initialProps: { defaultValue: selectedDate }, + }); + rerender({ defaultValue: nextDate }); + expect(result.current.value).toBe(selectedDate); + expect(result.current.text).toBe(selectedDate.toDateString()); + }); + + it('keeps editable draft text separate from selection and preserves it across rerenders', () => { + const onDateChange = jest.fn(); + const parseDate = jest.fn(); + const { result, rerender } = renderHook(() => + useDatePickerDateState({ allowTextInput: true, defaultValue: selectedDate, onDateChange, parseDate }), + ); + act(() => result.current.setText('unfinished')); + rerender(); + expect(result.current.text).toBe('unfinished'); + expect(result.current.value).toBe(selectedDate); + expect(onDateChange).not.toHaveBeenCalled(); + expect(parseDate).not.toHaveBeenCalled(); + }); + + it('commits a parsed value, normalizes text, and preserves the originating event', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + allowTextInput: true, + parseDate: () => nextDate, + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.setText('January 20')); + act(() => result.current.validate(event)); + expect(result.current.value).toBe(nextDate); + expect(result.current.text).toBe(nextDate.toDateString()); + expect(onDateChange).toHaveBeenCalledTimes(1); + expect(onDateChange).toHaveBeenCalledWith(event, { + type: 'blur', + event, + value: nextDate, + selectedDateRange: [nextDate], + reason: 'input', + }); + expect(onValidationResult).toHaveBeenCalledWith(event, { type: 'blur', event, error: undefined }); + act(() => result.current.validate(new Event('keydown'))); + expect(onDateChange).toHaveBeenCalledTimes(1); + }); + + it('accepts the Unix epoch using the default parser', () => { + const { result } = renderHook(() => useDatePickerDateState({ allowTextInput: true })); + act(() => result.current.setText('1970-01-01T00:00:00.000Z')); + act(() => result.current.validate(event)); + expect(result.current.value?.getTime()).toBe(0); + }); + + it.each([null, new Date(NaN)])('restores committed text for parser failure %s', parsedDate => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + allowTextInput: true, + defaultValue: selectedDate, + parseDate: () => parsedDate, + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.setText('not a date')); + act(() => result.current.validate(event)); + expect(result.current.value).toBe(selectedDate); + expect(result.current.text).toBe(selectedDate.toDateString()); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'invalid-input', + }); + }); + + it('restores empty text when parsing fails without an existing selection', () => { + const { result } = renderHook(() => useDatePickerDateState({ allowTextInput: true })); + act(() => result.current.setText('not a date')); + act(() => result.current.validate(event)); + expect(result.current.value).toBeNull(); + expect(result.current.text).toBe(''); + }); + + it.each([false, true])('clears selection when required is %s', required => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const parseDate = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + defaultValue: selectedDate, + allowTextInput: true, + required, + parseDate, + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.setText('')); + act(() => result.current.validate(event)); + expect(result.current.value).toBeNull(); + expect(result.current.text).toBe(''); + expect(parseDate).not.toHaveBeenCalled(); + expect(onDateChange).toHaveBeenCalledWith(event, { + type: 'blur', + event, + value: null, + selectedDateRange: [], + reason: 'clear', + }); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: required ? 'required-input' : undefined, + }); + act(() => result.current.validate(event)); + expect(onDateChange).toHaveBeenCalledTimes(1); + }); + + it('does not parse unchanged ambiguous formatted text', () => { + const parseDate = jest.fn(); + const formatDate = () => 'A special occasion'; + const { result } = renderHook(() => + useDatePickerDateState({ defaultValue: selectedDate, allowTextInput: true, formatDate, parseDate }), + ); + act(() => result.current.validate(event)); + act(() => result.current.setText('A special occasion')); + act(() => result.current.validate(event)); + expect(parseDate).not.toHaveBeenCalled(); + expect(result.current.value).toBe(selectedDate); + }); + + it('keeps an unavailable draft visible without changing selection', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + defaultValue: selectedDate, + allowTextInput: true, + parseDate: () => nextDate, + isDateUnavailable: date => date === nextDate, + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.setText('January 20')); + act(() => result.current.validate(event)); + expect(result.current.value).toBe(selectedDate); + expect(result.current.text).toBe('January 20'); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'out-of-bounds', + }); + }); + + it('validates availability of unchanged selection without parsing or clamping it', () => { + const parseDate = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + value: selectedDate, + allowTextInput: true, + isDateUnavailable: () => true, + parseDate, + onValidationResult, + }), + ); + act(() => result.current.validate(event)); + expect(parseDate).not.toHaveBeenCalled(); + expect(result.current.value).toBe(selectedDate); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'out-of-bounds', + }); + }); + + it('uses the latest availability policy on validation', () => { + const onValidationResult = jest.fn(); + const initialProps: DatePickerDateStateOptions = { + value: selectedDate, + onValidationResult, + isDateUnavailable: () => false, + }; + const { result, rerender } = renderHook((options: DatePickerDateStateOptions) => useDatePickerDateState(options), { + initialProps, + }); + rerender({ value: selectedDate, onValidationResult, isDateUnavailable: () => true }); + act(() => result.current.validate(event)); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'out-of-bounds', + }); + }); + + it('preserves Calendar ranges without generating a second range', () => { + const onDateChange = jest.fn(); + const range = [selectedDate, nextDate]; + const click = new Event('click'); + const { result } = renderHook(() => useDatePickerDateState({ onDateChange })); + act(() => result.current.selectDate(click, nextDate, range)); + expect(result.current.value).toBe(nextDate); + expect(result.current.text).toBe(nextDate.toDateString()); + expect(onDateChange).toHaveBeenCalledWith(click, { + type: 'click', + event: click, + value: nextDate, + selectedDateRange: range, + reason: 'calendar', + }); + }); + + it.each(['invalid-input', 'out-of-bounds'])('rejects an %s Calendar selection explicitly', error => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ onDateChange, onValidationResult, isDateUnavailable: () => true }), + ); + const date = error === 'invalid-input' ? new Date(NaN) : nextDate; + act(() => result.current.selectDate(event, date, [date])); + expect(result.current.value).toBeNull(); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).toHaveBeenCalledWith(event, { type: 'blur', event, error }); + }); + + it('keeps a controlled owner authoritative when a selection request is rejected', () => { + const onDateChange = jest.fn(); + const { result } = renderHook(() => useDatePickerDateState({ value: selectedDate, onDateChange })); + act(() => result.current.selectDate(event, nextDate, [nextDate])); + expect(onDateChange).toHaveBeenCalledTimes(1); + expect(result.current.value).toBe(selectedDate); + expect(result.current.text).toBe(selectedDate.toDateString()); + }); + + it('keeps a controlled owner authoritative when a typed commit is rejected', () => { + const { result } = renderHook(() => + useDatePickerDateState({ value: selectedDate, allowTextInput: true, parseDate: () => nextDate }), + ); + act(() => result.current.setText('January 20')); + act(() => result.current.validate(event)); + expect(result.current.value).toBe(selectedDate); + expect(result.current.text).toBe(selectedDate.toDateString()); + }); + + it('reflects controlled updates and clearing without interaction callbacks', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const initialProps: DatePickerDateStateOptions = { + value: selectedDate, + allowTextInput: true, + onDateChange, + onValidationResult, + }; + const { result, rerender } = renderHook((options: DatePickerDateStateOptions) => useDatePickerDateState(options), { + initialProps, + }); + act(() => result.current.setText('unfinished')); + rerender({ value: nextDate, allowTextInput: true, onDateChange, onValidationResult }); + expect(result.current.text).toBe(nextDate.toDateString()); + rerender({ value: null, allowTextInput: true, onDateChange, onValidationResult }); + expect(result.current.text).toBe(''); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).not.toHaveBeenCalled(); + }); + + it('reflects formatter changes without changing selection', () => { + const { result, rerender } = renderHook((options: DatePickerDateStateOptions) => useDatePickerDateState(options), { + initialProps: { defaultValue: selectedDate, formatDate: () => 'first format' }, + }); + rerender({ defaultValue: selectedDate, formatDate: () => 'second format' }); + expect(result.current.text).toBe('second format'); + expect(result.current.value).toBe(selectedDate); + }); + + it('reports a supplied invalid Date instead of sending it to the formatter', () => { + const formatDate = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ value: new Date(NaN), formatDate, onValidationResult }), + ); + act(() => result.current.validate(event)); + expect(formatDate).not.toHaveBeenCalled(); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'invalid-input', + }); + }); + + it('does not edit selection-only input and reports required validation', () => { + const onValidationResult = jest.fn(); + const { result } = renderHook(() => useDatePickerDateState({ required: true, onValidationResult })); + act(() => result.current.setText('January 20')); + expect(result.current.text).toBe(''); + act(() => result.current.validate(event)); + expect(onValidationResult).toHaveBeenCalledWith(event, { + type: 'blur', + event, + error: 'required-input', + }); + }); + + it('does not edit, select, or validate when disabled', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ allowTextInput: true, disabled: true, onDateChange, onValidationResult }), + ); + act(() => { + result.current.setText('January 20'); + result.current.selectDate(event, nextDate, [nextDate]); + result.current.validate(event); + }); + expect(result.current.text).toBe(''); + expect(result.current.value).toBeNull(); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).not.toHaveBeenCalled(); + }); + + it('does not swallow a consumer parser exception', () => { + const failure = new Error('Parser failed'); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerDateState({ + allowTextInput: true, + parseDate: () => { + throw failure; + }, + onValidationResult, + }), + ); + act(() => result.current.setText('January 20')); + expect(() => act(() => result.current.validate(event))).toThrow(failure); + expect(onValidationResult).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts new file mode 100644 index 0000000000000..5b4de3934d1a1 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts @@ -0,0 +1,176 @@ +'use client'; + +import * as React from 'react'; +import { useControllableState, useEventCallback } from '@fluentui/react-utilities'; +import type { EventData, EventHandler } from '@fluentui/react-utilities'; + +type DatePickerEvent = React.SyntheticEvent | Event; + +export type DatePickerErrorType = 'invalid-input' | 'out-of-bounds' | 'required-input'; + +export type DatePickerDateChangeData = EventData & { + value: Date | null; + selectedDateRange: Date[]; + reason: 'calendar' | 'input' | 'clear'; +}; + +export type DatePickerValidationResultData = EventData & { + error?: DatePickerErrorType; +}; + +/** Date-input state shared by the styled and headless component integrations. */ +export type DatePickerDateStateOptions = { + value?: Date | null; + defaultValue?: Date | null; + allowTextInput?: boolean; + disabled?: boolean; + required?: boolean; + formatDate?: (date: Date) => string; + parseDate?: (text: string) => Date | null; + /** The Calendar integration supplies its availability check, including boundaries and restrictions. */ + isDateUnavailable?: (date: Date) => boolean; + onDateChange?: EventHandler; + onValidationResult?: EventHandler; +}; + +export type DatePickerDateState = { + value: Date | null; + text: string; + setText: (text: string) => void; + validate: (event: DatePickerEvent) => void; + selectDate: (event: DatePickerEvent, date: Date, selectedDateRange: Date[]) => void; +}; + +const defaultFormatDate = (date: Date): string => date.toDateString(); + +const defaultParseDate = (text: string): Date | null => { + const timestamp = Date.parse(text); + return Number.isFinite(timestamp) ? new Date(timestamp) : null; +}; + +const isValidDate = (date: Date): boolean => Number.isFinite(date.getTime()); + +/** + * Owns committed selection and editable text without depending on Input or Calendar. + * Callers validate once per logical action, rather than separately for its blur and dismissal events. + */ +export const useDatePickerDateState = (options: DatePickerDateStateOptions): DatePickerDateState => { + const { + allowTextInput = false, + disabled = false, + required = false, + formatDate = defaultFormatDate, + parseDate = defaultParseDate, + isDateUnavailable, + onDateChange, + onValidationResult, + } = options; + const [value, setValue] = useControllableState({ + state: options.value, + defaultState: options.defaultValue, + initialState: null, + }); + const formatValue = (date: Date | null): string => (date && isValidDate(date) ? formatDate(date) : ''); + const timestamp = value?.getTime(); + const [draft, setDraft] = React.useState(() => ({ + text: formatValue(value), + dirty: false, + timestamp, + formatter: formatDate, + })); + + if (!Object.is(draft.timestamp, timestamp) || draft.formatter !== formatDate) { + setDraft({ text: formatValue(value), dirty: false, timestamp, formatter: formatDate }); + } + + const updateText = (text: string, dirty: boolean) => setDraft({ text, dirty, timestamp, formatter: formatDate }); + + const reportValidation = (event: DatePickerEvent, error?: DatePickerErrorType) => { + onValidationResult?.(event, { type: event.type, event, error }); + }; + + const commit = ( + event: DatePickerEvent, + date: Date | null, + selectedDateRange: Date[], + reason: DatePickerDateChangeData['reason'], + ) => { + setValue(date); + updateText(formatValue(options.value !== undefined ? value : date), false); + onDateChange?.(event, { type: event.type, event, value: date, selectedDateRange, reason }); + }; + + const validate = useEventCallback((event: DatePickerEvent) => { + if (disabled) { + return; + } + + if (!allowTextInput || (!draft.dirty && draft.text === formatValue(value))) { + let error: DatePickerErrorType | undefined; + if (value) { + if (!isValidDate(value)) { + error = 'invalid-input'; + } else if (isDateUnavailable?.(value)) { + error = 'out-of-bounds'; + } + } else if (required) { + error = 'required-input'; + } + reportValidation(event, error); + return; + } + + if (!draft.text) { + if (draft.dirty || value !== null) { + commit(event, null, [], 'clear'); + } + reportValidation(event, required ? 'required-input' : undefined); + return; + } + + // A custom formatter can produce text which its parser cannot round-trip. + if (value && isValidDate(value) && draft.text === formatDate(value)) { + updateText(draft.text, false); + reportValidation(event, isDateUnavailable?.(value) ? 'out-of-bounds' : undefined); + return; + } + + const parsedDate = parseDate(draft.text); + if (!parsedDate || !isValidDate(parsedDate)) { + updateText(formatValue(value), false); + reportValidation(event, 'invalid-input'); + return; + } + if (isDateUnavailable?.(parsedDate)) { + reportValidation(event, 'out-of-bounds'); + return; + } + + commit(event, parsedDate, [parsedDate], 'input'); + reportValidation(event); + }); + + const selectDate = useEventCallback((event: DatePickerEvent, date: Date, selectedDateRange: Date[]) => { + if (disabled) { + return; + } + if (!isValidDate(date)) { + reportValidation(event, 'invalid-input'); + return; + } + if (isDateUnavailable?.(date)) { + reportValidation(event, 'out-of-bounds'); + return; + } + commit(event, date, selectedDateRange, 'calendar'); + reportValidation(event); + }); + + const setText = useEventCallback((text: string) => { + if (allowTextInput && !disabled) { + updateText(text, true); + } + }); + + return { value, text: draft.text, setText, validate, selectDate }; +}; From ea048af01d8d8c3f29d0c156c1b3f09b7fa687f2 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 17 Oct 2026 16:18:49 +0200 Subject: [PATCH 04/11] allow components composition --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 684888250417b..78b29ef511ad6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,7 @@ from existing code without verifying they match these instructions. 1. **Never hardcode colors, spacing, or typography values.** Always use design tokens from `@fluentui/react-theme`. See [docs/architecture/design-tokens.md](docs/architecture/design-tokens.md). 2. **Never use `React.FC`.** Always use `ForwardRefComponent` with `React.forwardRef`. 3. **Never access `window`, `document`, or `navigator` directly.** In v9 components, use `useFluent_unstable()` to get `targetDocument` and `targetDocument.defaultView` instead of `document`/`window`. For non-component code, use `canUseDOM()` from `@fluentui/react-utilities`. -4. **Never add dependencies between component packages.** `react-button` must not depend on `react-menu`. Shared logic goes in `react-utilities` or `react-shared-contexts`. See [docs/architecture/layers.md](docs/architecture/layers.md). -5. **Never skip beachball change files** for published package changes. Run `yarn beachball change`. +4. **Never skip beachball change files** for published package changes. Run `yarn beachball change`. ## V9 Component Template (the correct pattern) From 18c836611c472350a563c483972f41ca368159d3 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 18 Oct 2026 09:49:20 +0200 Subject: [PATCH 05/11] feat(react-datepicker-preview): complete DatePicker hooks, behavior and stories Implement Input and Calendar composition, validation, popup positioning and dismissal, accessible focus management, SSR-safe portals, public base hooks, API docs, browser and compatibility tests, stories and paired visual fixtures. Keep the package private until its separate release-preparation layer. Depends on the standalone Calendar popup focus and geometry fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/vr-tests-react-components/package.json | 1 + .../DatePickerPreview.stories.tsx | 188 ++++++ .../library/README.md | 26 +- .../library/config/tests.cjs | 3 + .../library/cypress.config.ts | 3 + .../library/docs/Spec.md | 11 +- .../etc/react-datepicker-preview.api.md | 76 ++- .../library/package.json | 13 +- .../library/project.json | 12 +- .../library/rit.config.js | 11 + .../library/src/DatePicker.ts | 14 +- .../components/DatePicker/DatePicker.cy.tsx | 261 +++++++++ .../components/DatePicker/DatePicker.test.tsx | 542 +++++++++++++++++- .../src/components/DatePicker/DatePicker.tsx | 12 +- .../DatePicker/DatePicker.types.test.tsx | 67 +++ .../components/DatePicker/DatePicker.types.ts | 93 ++- .../__snapshots__/DatePicker.test.tsx.snap | 42 +- .../src/components/DatePicker/defaults.ts | 8 + .../src/components/DatePicker/index.ts | 14 +- .../DatePicker/renderDatePicker.tsx | 15 +- .../components/DatePicker/useDatePicker.ts | 31 - .../components/DatePicker/useDatePicker.tsx | 364 ++++++++++++ .../DatePicker/useDatePickerBase.test.ts | 136 +++++ .../DatePicker/useDatePickerBase.ts | 48 ++ .../DatePicker/useDatePickerStyles.styles.ts | 63 +- .../library/src/index.ts | 14 +- .../library/tsconfig.cy.json | 10 + .../library/tsconfig.json | 3 + .../library/tsconfig.lib.json | 4 +- .../DatePickerAppearance.stories.tsx | 23 + .../src/DatePicker/DatePickerBestPractices.md | 11 + .../DatePickerCalendarComposition.stories.tsx | 29 + .../DatePickerControlled.stories.tsx | 23 + ...DatePickerCustomDateFormatting.stories.tsx | 17 + .../DatePickerDateBoundaries.stories.tsx | 16 + .../DatePickerDateRange.stories.tsx | 17 + .../DatePicker/DatePickerDefault.stories.tsx | 7 +- .../DatePickerDefaultOpen.stories.tsx | 10 + .../DatePickerDefaultOpenInline.stories.tsx | 10 + .../src/DatePicker/DatePickerDescription.md | 6 + .../DatePicker/DatePickerDisabled.stories.tsx | 10 + .../DatePickerErrorHandling.stories.tsx | 20 + .../DatePickerFirstDayOfTheWeek.stories.tsx | 10 + .../DatePickerLocalized.stories.tsx | 40 ++ .../DatePickerPopupOptions.stories.tsx | 10 + .../DatePicker/DatePickerRequired.stories.tsx | 10 + .../DatePicker/DatePickerSizes.stories.tsx | 21 + .../DatePickerTextInput.stories.tsx | 10 + .../DatePickerWeekNumbers.stories.tsx | 13 + .../stories/src/DatePicker/dateFormatting.ts | 13 + .../stories/src/DatePicker/index.stories.tsx | 17 + yarn.lock | 28 +- 52 files changed, 2350 insertions(+), 106 deletions(-) create mode 100644 apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/cypress.config.ts create mode 100644 packages/react-components/react-datepicker-preview/library/rit.config.js create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.cy.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.test.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/defaults.ts delete mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.test.ts create mode 100644 packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.ts create mode 100644 packages/react-components/react-datepicker-preview/library/tsconfig.cy.json create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerAppearance.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCalendarComposition.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerControlled.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateBoundaries.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateRange.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpen.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpenInline.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDisabled.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerErrorHandling.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerFirstDayOfTheWeek.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerPopupOptions.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerRequired.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerSizes.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerTextInput.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerWeekNumbers.stories.tsx create mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 3b27a76b647f7..7a0fdc69697a0 100644 --- a/apps/vr-tests-react-components/package.json +++ b/apps/vr-tests-react-components/package.json @@ -28,6 +28,7 @@ "@fluentui/react-combobox": "*", "@fluentui/react-context-selector": "*", "@fluentui/react-datepicker-compat": "*", + "@fluentui/react-datepicker-preview": "workspace:^", "@fluentui/react-dialog": "*", "@fluentui/react-divider": "*", "@fluentui/react-drawer": "*", diff --git a/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx b/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx new file mode 100644 index 0000000000000..bfde2e5d06e8d --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react-webpack5'; +import { Steps } from 'storywright'; +import type { StoryParameters } from 'storywright'; +import { makeStyles } from '@griffel/react'; +import { teamsLightTheme, tokens } from '@fluentui/react-theme'; +import { FluentProvider } from '@fluentui/react-provider'; +import { DatePicker as DatePickerCompat } from '@fluentui/react-datepicker-compat'; +import { DateRangeType, DayOfWeek } from '@fluentui/react-calendar-compat'; +import type { DatePickerProps as CompatProps } from '@fluentui/react-datepicker-compat'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; +import type { DatePickerProps } from '@fluentui/react-datepicker-preview'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { DARK_MODE, HIGH_CONTRAST, RTL, getStoryVariant, TestWrapperDecorator } from '../../utilities'; + +const today = new Date(2030, 0, 15); +const useStyles = makeStyles({ + pair: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalXXXL }, + example: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: tokens.spacingVerticalM }, +}); + +export default { + title: 'DatePicker Preview', + component: DatePicker, + decorators: [TestWrapperDecorator], + parameters: { + storyWright: { steps: new Steps().snapshot('rest', { cropTo: '.testWrapper' }).end() }, + } satisfies StoryParameters, +} satisfies Meta; + +const Pair = ({ preview = {}, compat = {} }: { preview?: DatePickerProps; compat?: CompatProps }): JSXElement => { + const styles = useStyles(); + const [compatSize, setCompatSize] = React.useState({ width: 0, height: 0 }); + const [previewSize, setPreviewSize] = React.useState({ width: 0, height: 0 }); + const measureCompat = React.useCallback((node: HTMLDivElement | null) => { + const surface = node?.querySelector('[role="dialog"]'); + if (surface) { + const { width, height } = surface.getBoundingClientRect(); + setCompatSize({ width, height }); + } + }, []); + const measurePreview = React.useCallback((node: HTMLDivElement | null) => { + const surface = node?.querySelector('[role="dialog"]'); + if (surface) { + const { width, height } = surface.getBoundingClientRect(); + setPreviewSize({ width, height }); + } + }, []); + return ( +
+
+ Compat reference + +
+
+ Preview + +
+
+ ); +}; + +export const Default = (): JSXElement => ; +export const Selected = (): JSXElement => ; +export const Open = (): JSXElement => ; +export const Editable = (): JSXElement => ; +export const Disabled = (): JSXElement => ; +export const Invalid = (): JSXElement => ; +export const Underline = (): JSXElement => ; +export const FilledLighter = (): JSXElement => ( + +); +export const Small = (): JSXElement => ; +export const Large = (): JSXElement => ; +export const SideBySide = (): JSXElement => ( + +); +export const MonthOnly = (): JSXElement => ( + +); +export const WeekRange = (): JSXElement => ( + +); +export const WorkWeekRange = (): JSXElement => ( + +); +export const MonthRange = (): JSXElement => ( + +); +export const Restricted = (): JSXElement => ( + +); +export const DayOnly = (): JSXElement => ( + +); +export const Overlay = (): JSXElement => ( + +); +export const WeekNumbers = (): JSXElement => ( + +); +export const OpenDark = getStoryVariant(Open, DARK_MODE); +export const OpenHighContrast = getStoryVariant(Open, HIGH_CONTRAST); +export const OpenRtl = getStoryVariant(Open, RTL); +export const OpenTeamsTheme = (): JSXElement => ( + + + +); +export const SmallOpen = (): JSXElement => ( + +); +export const LargeOpen = (): JSXElement => ( + +); +export const YearView = (): JSXElement => ; +YearView.parameters = { + storyWright: { + steps: new Steps() + .click('.fui-CalendarPicker__currentItemButton') + .click('.fui-CalendarMonth__heading') + .snapshot('year pickers', { cropTo: '.testWrapper' }) + .end(), + }, +} satisfies StoryParameters; +export const Focused = (): JSXElement => ; +Focused.parameters = { + storyWright: { + steps: new Steps() + .focus('[data-testid="compat"] input') + .snapshot('compat focus', { cropTo: '.testWrapper' }) + .focus('[data-testid="preview"] input') + .snapshot('preview focus', { cropTo: '.testWrapper' }) + .end(), + }, +} satisfies StoryParameters; + +export const Popup = (): JSXElement => ; +Popup.parameters = { + storyWright: { + steps: new Steps() + .snapshot('positioned', { cropTo: 'body' }) + .keys('[role="dialog"]', 'Escape') + .snapshot('dismissed', { cropTo: 'body' }) + .end(), + }, +} satisfies StoryParameters; diff --git a/packages/react-components/react-datepicker-preview/library/README.md b/packages/react-components/react-datepicker-preview/library/README.md index 2c79a9cc8bb26..791b8d602777d 100644 --- a/packages/react-components/react-datepicker-preview/library/README.md +++ b/packages/react-components/react-datepicker-preview/library/README.md @@ -4,10 +4,24 @@ These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. -The exported DatePicker is currently a scaffold, not a usable date input. The proposed API and parity -requirements are described in the [spec](./docs/Spec.md). +DatePicker composes Input and Calendar Preview with controlled/uncontrolled date selection, optional +text editing, validation callbacks, popup positioning, and modal focus management. Use Field for labels +and validation presentation. See the [spec](./docs/Spec.md) for the API and parity requirements. -The internal `useDatePickerDateState` hook implements selection, editable draft text, parsing, and -validation for reuse by the styled and headless integrations. It is not yet connected to the scaffold -or exported as a supported API. Calendar availability is supplied by the integration so the hook does -not duplicate Calendar's date arithmetic or range generation. +```tsx + + + +``` + +`useDatePickerBase_unstable` shares date-input state, availability checks, and popup requests without +visual components, positioning, or Tabster. Custom integrations own their slots, focus, and dismissal +behavior. `useDatePicker_unstable` adds the standard Input/Calendar slots and popup integration. + +For server rendering, wrap the application in Fluent UI's `SSRProvider`. An initially open portaled +popup mounts after hydration; an inline popup can be included in the server markup. Popup focus entry +and return use `preventScroll` so they do not trigger outside-scroll dismissal. + +Automated interaction tests and example stories cover the supported API. Paired visual fixtures are +provided, but exact compat visual parity and manual screen-reader verification remain release gates; +this component is not yet certified as a production replacement for DatePicker Compat. diff --git a/packages/react-components/react-datepicker-preview/library/config/tests.cjs b/packages/react-components/react-datepicker-preview/library/config/tests.cjs index 2e211ae9e2142..0d1d3e044a172 100644 --- a/packages/react-components/react-datepicker-preview/library/config/tests.cjs +++ b/packages/react-components/react-datepicker-preview/library/config/tests.cjs @@ -1 +1,4 @@ /** Jest test setup file. */ + +// Explicit IDs make the DatePicker snapshot stable across React versions. +process.env.RIT_SKIP_SNAPSHOTS = 'false'; diff --git a/packages/react-components/react-datepicker-preview/library/cypress.config.ts b/packages/react-components/react-datepicker-preview/library/cypress.config.ts new file mode 100644 index 0000000000000..ca52cf041bbf2 --- /dev/null +++ b/packages/react-components/react-datepicker-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-datepicker-preview/library/docs/Spec.md b/packages/react-components/react-datepicker-preview/library/docs/Spec.md index 679e4151df123..3eb5cdcc28636 100644 --- a/packages/react-components/react-datepicker-preview/library/docs/Spec.md +++ b/packages/react-components/react-datepicker-preview/library/docs/Spec.md @@ -1,6 +1,7 @@ # @fluentui/react-datepicker-preview Spec -**Status:** Proposed. This document specifies the target component, not the current scaffold's implemented API. +**Status:** Implemented in preview. Exact compat visual parity and manual assistive-technology +verification remain release gates; see Acceptance Criteria and Verification. ## Background @@ -38,7 +39,7 @@ compat prop comments alone. ## Sample Code -Examples use the proposed API; they are not examples of the current scaffold. +Examples use the preview API. ### Basic usage @@ -119,7 +120,7 @@ There is no additional clear button, time picker, multiple-date selection, or st ## API -The implementation must replace the scaffold in +The public API is defined in [DatePicker.types.ts](../src/components/DatePicker/DatePicker.types.ts) with this contract. ### Slots and ref @@ -339,8 +340,8 @@ not an undocumented "v9 improvement". ### Compat-to-preview migration walkthrough This walkthrough targets `@fluentui/react-datepicker-compat` consumers. The destination code uses -the **proposed** API in this spec and is not runnable against the current scaffold. Migrate only -after that API is implemented and parity is verified; keep production consumers on compat meanwhile. +the preview API in this spec. Migrate production consumers only after parity is verified; +keep production consumers on compat meanwhile. #### 1. Replace imports and adapt controlled callbacks diff --git a/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md b/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md index e2b71f8801117..72404bf4d6b2e 100644 --- a/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md +++ b/packages/react-components/react-datepicker-preview/library/etc/react-datepicker-preview.api.md @@ -4,31 +4,99 @@ ```ts +import type { Calendar } from '@fluentui/react-calendar-preview'; +import type { CalendarProps } from '@fluentui/react-calendar-preview'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import type { EventData } from '@fluentui/react-utilities'; +import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { Input } from '@fluentui/react-input'; import type { JSXElement } from '@fluentui/react-utilities'; -import type * as React_2 from 'react'; +import type { PortalProps } from '@fluentui/react-portal'; +import type { PositioningProps } from '@fluentui/react-positioning'; +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 const DatePicker: ForwardRefComponent; +// @public +export type DatePickerBaseProps = Omit; + +// @public +export type DatePickerBaseState = DatePickerDateState & { + open: boolean; + today: Date; + required?: boolean; + requestOpen: (event: React_2.SyntheticEvent | Event, open: boolean, reason: DatePickerOpenChangeData['reason']) => void; +}; + // @public (undocumented) export const datePickerClassNames: SlotClassNames; +// @public (undocumented) +export type DatePickerDateChangeData = EventData & { + value: Date | null; + selectedDateRange: Date[]; + reason: 'calendar' | 'input' | 'clear'; +}; + +// @public (undocumented) +export type DatePickerErrorType = 'invalid-input' | 'out-of-bounds' | 'required-input'; + +// @public +export type DatePickerOpenChangeData = EventData & { + open: boolean; + reason: 'input' | 'icon' | 'keyboard' | 'focus' | 'selection' | 'dismiss'; +}; + // @public -export type DatePickerProps = ComponentProps & {}; +export type DatePickerProps = Omit>, 'value' | 'defaultValue' | 'type' | 'readOnly' | 'calendar'> & Omit & { + calendar?: NonNullable & RefAttributes>>; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: EventHandler; + openOnClick?: boolean; + openOnFocus?: boolean; + today?: Date; + minDate?: Date; + maxDate?: Date; + inlinePopup?: boolean; + mountNode?: PortalProps['mountNode']; + positioning?: PositioningProps; +}; + +// @public (undocumented) +export type DatePickerSlots = { + root: NonNullable>; + calendar: NonNullable>; + popupSurface?: Slot<'div'>; +}; // @public -export type DatePickerState = ComponentState; +export type DatePickerState = ComponentState & Required> & Pick & { + open: boolean; +}; + +// @public (undocumented) +export type DatePickerValidationResultData = EventData & { + error?: DatePickerErrorType; +}; + +// @public +export const defaultDatePickerErrorStrings: Record; // @public export const renderDatePicker_unstable: (state: DatePickerState) => JSXElement; // @public -export const useDatePicker_unstable: (props: DatePickerProps, ref: React_2.Ref) => DatePickerState; +export const useDatePicker_unstable: (props: DatePickerProps, ref: React_2.Ref) => DatePickerState; + +// @public +export const useDatePickerBase_unstable: (props: DatePickerBaseProps) => DatePickerBaseState; // @public export const useDatePickerStyles_unstable: (state: DatePickerState) => DatePickerState; diff --git a/packages/react-components/react-datepicker-preview/library/package.json b/packages/react-components/react-datepicker-preview/library/package.json index fa428df69af76..8d7c3e3fe3e4d 100644 --- a/packages/react-components/react-datepicker-preview/library/package.json +++ b/packages/react-components/react-datepicker-preview/library/package.json @@ -2,7 +2,7 @@ "name": "@fluentui/react-datepicker-preview", "version": "0.0.0", "private": true, - "description": "New fluentui react package", + "description": "DatePicker preview component for Fluent UI React", "type": "module", "main": "lib-commonjs/index.cjs", "module": "lib/index.js", @@ -21,8 +21,15 @@ }, "license": "MIT", "dependencies": { + "@fluentui/react-calendar-preview": "workspace:^", + "@fluentui/react-field": "workspace:^", + "@fluentui/react-icons": "^2.0.339", + "@fluentui/react-input": "workspace:^", "@fluentui/react-jsx-runtime": "^9.4.6", + "@fluentui/react-portal": "workspace:^", + "@fluentui/react-positioning": "workspace:^", "@fluentui/react-shared-contexts": "^9.26.4", + "@fluentui/react-tabster": "workspace:^", "@fluentui/react-theme": "^9.2.2", "@fluentui/react-utilities": "^9.26.7", "@griffel/react": "^1.5.32", @@ -55,5 +62,9 @@ "major", "prerelease" ] + }, + "devDependencies": { + "@fluentui/react-provider": "workspace:^", + "@fluentui/scripts-cypress": "workspace:^" } } diff --git a/packages/react-components/react-datepicker-preview/library/project.json b/packages/react-components/react-datepicker-preview/library/project.json index 3dab67782c857..916e2d91829c0 100644 --- a/packages/react-components/react-datepicker-preview/library/project.json +++ b/packages/react-components/react-datepicker-preview/library/project.json @@ -4,5 +4,15 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-datepicker-preview/library/src", "tags": ["platform:web", "vNext"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "e2e": { + "inputs": [ + "default", + "{projectRoot}/cypress.config.ts", + "{projectRoot}/src/**/*.cy.tsx", + { "externalDependencies": ["cypress", "@cypress/react"] } + ] + } + } } diff --git a/packages/react-components/react-datepicker-preview/library/rit.config.js b/packages/react-components/react-datepicker-preview/library/rit.config.js new file mode 100644 index 0000000000000..da5828f347f92 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/rit.config.js @@ -0,0 +1,11 @@ +const runConfig = { + test: { configPath: 'jest.config.cjs', command: 'jest --runInBand' }, + e2e: { configPath: 'cypress.config.ts', command: 'cypress run --component --browser chrome' }, +}; + +export default { + react: { + 17: { runConfig }, + 18: { runConfig }, + }, +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts b/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts index bd248a994b0e0..86c0abfa9cd64 100644 --- a/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts +++ b/packages/react-components/react-datepicker-preview/library/src/DatePicker.ts @@ -1,8 +1,20 @@ export { datePickerClassNames, + defaultDatePickerErrorStrings, DatePicker, renderDatePicker_unstable, useDatePicker_unstable, + useDatePickerBase_unstable, useDatePickerStyles_unstable, } from './components/DatePicker/index'; -export type { DatePickerProps, DatePickerState } from './components/DatePicker/index'; +export type { + DatePickerBaseProps, + DatePickerBaseState, + DatePickerDateChangeData, + DatePickerErrorType, + DatePickerOpenChangeData, + DatePickerProps, + DatePickerSlots, + DatePickerState, + DatePickerValidationResultData, +} from './components/DatePicker/index'; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.cy.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.cy.tsx new file mode 100644 index 0000000000000..815545d19c831 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.cy.tsx @@ -0,0 +1,261 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { FluentProvider } from '@fluentui/react-provider'; +import { webLightTheme } from '@fluentui/react-theme'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { DatePicker } from './DatePicker'; +import type { DatePickerProps } from './DatePicker.types'; + +const today = new Date(2030, 0, 15); +const mount = (element: JSXElement) => mountBase({element}); + +const IframeExample = ({ onOpenChange }: Pick): JSXElement => { + const [frameDocument, setFrameDocument] = React.useState(); + return ( + + ); +}; + +describe('DatePicker Preview browser interactions', () => { + it('opens from the input and restores focus after Escape without reopening', () => { + mount(); + cy.get('[role="combobox"]').focus(); + cy.get('[role="dialog"]').should('be.visible'); + cy.realPress('Escape'); + cy.get('[role="dialog"]').should('not.exist'); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('contains forward and backward Tab navigation in the dialog', () => { + mount(); + cy.get('[role="combobox"]').focus().realPress('ArrowDown'); + cy.get('[role="dialog"]').should('be.visible'); + for (let index = 0; index < 12; index++) { + cy.realPress('Tab'); + cy.focused().closest('[role="dialog"]').should('exist'); + } + for (let index = 0; index < 12; index++) { + cy.realPress(['Shift', 'Tab']); + cy.focused().closest('[role="dialog"]').should('exist'); + } + }); + + it('selects a day once, unmounts the popup, and returns input focus', () => { + const onDateChange = cy.stub().as('dateChange'); + const onOpenChange = cy.stub().as('openChange'); + mount(); + cy.get('[role="combobox"]').click(); + cy.get('button[aria-label="January 20, 2030"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + cy.get('[role="combobox"]').should('have.value', new Date(2030, 0, 20).toDateString()); + cy.focused().should('have.attr', 'role', 'combobox'); + cy.get('@dateChange').should('have.been.calledOnce'); + cy.get('@openChange').should('have.been.calledTwice'); + }); + + it('dismisses outside without stealing focus from the activated control', () => { + mount( + <> + + + , + ); + cy.get('[role="combobox"]').click(); + cy.contains('button', 'Outside').click(); + cy.get('[role="dialog"]').should('not.exist'); + cy.focused().should('have.text', 'Outside'); + }); + + it('normalizes editable text, restores invalid text, and clears Calendar selection', () => { + mount(); + cy.get('[role="combobox"]').type('January 20, 2030').blur(); + cy.get('[role="combobox"]').should('have.value', new Date(2030, 0, 20).toDateString()); + cy.get('[role="combobox"]').clear().type('not a date').blur(); + cy.get('[role="combobox"]').should('have.value', new Date(2030, 0, 20).toDateString()); + cy.get('[role="combobox"]').clear().blur(); + cy.get('[role="combobox"]').focus().realPress('ArrowDown'); + cy.get('[role="dialog"] [data-fui-calendar-picker="day"] [aria-selected="true"]').should('not.exist'); + }); + + it('does not offer a popup when disabled, even with controlled open', () => { + mount(); + cy.get('[role="combobox"]').should('be.disabled').click({ force: true }); + cy.get('[role="dialog"]').should('not.exist'); + }); + + it('uses inline placement and restores focus after selection', () => { + mount(); + cy.get('[role="combobox"]').click(); + cy.get('[role="dialog"]').closest('[data-portal-node]').should('not.exist'); + cy.get('button[aria-label="January 20, 2030"]').click(); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('uses a custom mount node for the popup', () => { + const CustomMount = (): JSXElement => { + const [mountNode, setMountNode] = React.useState(null); + return ( + <> +
+ + + ); + }; + mount(); + cy.get('[role="combobox"]').click(); + cy.get('[data-testid="custom-mount"] [role="dialog"]').should('be.visible'); + cy.realPress('Escape'); + cy.get('[data-testid="custom-mount"] [role="dialog"]').should('not.exist'); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('keeps focus and outside interactions in the provider iframe document', () => { + const requests: string[] = []; + mount( requests.push(`${event.type}:${data.open}`)} />); + cy.get('iframe[title="DatePicker document"]') + .its('0.contentDocument.body') + .should('not.be.empty') + .then((body: HTMLBodyElement) => { + const frameDocument = body.ownerDocument; + cy.wrap(frameDocument.body).find('[role="combobox"]').click(); + cy.then(() => expect(requests).to.deep.equal(['click:true'])); + cy.wrap(frameDocument.body) + .find('[role="dialog"]') + .should('be.visible') + .then(dialog => { + expect(dialog[0].ownerDocument).to.equal(frameDocument); + expect(dialog[0].contains(frameDocument.activeElement)).to.equal(true); + }); + cy.get('[role="dialog"]').should('not.exist'); + cy.wrap(frameDocument.body).contains('button', 'Outside in frame').click(); + cy.wrap(frameDocument.body).find('[role="dialog"]').should('not.exist'); + cy.then(() => expect(frameDocument.activeElement?.textContent).to.equal('Outside in frame')); + }); + }); + + it('keeps focus in the popup when all dates are unavailable', () => { + mount( + , + ); + cy.get('[role="dialog"]').should('be.visible'); + cy.focused().closest('[role="dialog"]').should('exist'); + cy.realPress('Escape'); + cy.get('[role="dialog"]').should('not.exist'); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('keeps the default adaptive calendar usable in a narrow RTL viewport', () => { + const requests: string[] = []; + cy.viewport(320, 640); + mountBase( + + requests.push(`${event.type}:${data.open}`)} /> + , + ); + cy.get('[role="combobox"]').click(); + cy.then(() => expect(requests).to.deep.equal(['click:true'])); + cy.get('[role="dialog"]') + .should('be.visible') + .then(dialog => { + const bounds = dialog[0].getBoundingClientRect(); + expect(bounds.left).to.be.at.least(0); + expect(bounds.right).to.be.at.most(320); + }); + cy.get('button[aria-label="January 20, 2030"]').click(); + cy.get('[role="combobox"]').should('have.value', new Date(2030, 0, 20).toDateString()); + }); + + it('navigates year and month views without committing until a day is selected', () => { + const onDateChange = cy.stub().as('dateChange'); + mount(); + cy.get('[role="combobox"]').click(); + cy.get('.fui-CalendarDay__monthAndYear').click(); + cy.get('.fui-CalendarMonth__heading').click(); + cy.contains('[role="gridcell"]', '2031').click(); + cy.get('[role="gridcell"][aria-label="February"]').click(); + cy.get('@dateChange').should('not.have.been.called'); + cy.get('button[aria-label="February 20, 2031"]').click(); + cy.get('@dateChange').should('have.been.calledOnce'); + cy.get('[role="combobox"]').should('have.value', new Date(2031, 1, 20).toDateString()); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('handles external controlled close without reopening from restored focus', () => { + const Controlled = (): JSXElement => { + const [open, setOpen] = React.useState(true); + return ( + { + if (event.key === 'F2') { + setOpen(false); + } + }, + }} + /> + ); + }; + mount(); + cy.get('[role="dialog"]').should('be.visible'); + cy.focused().closest('[role="dialog"]').should('exist'); + cy.realPress('F2'); + cy.get('[role="dialog"]').should('not.exist'); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + it('remains operable with forced colors and reduced motion', () => { + cy.then(() => + Cypress.automation('remote:debugger:protocol', { + command: 'Emulation.setEmulatedMedia', + params: { + features: [ + { name: 'forced-colors', value: 'active' }, + { name: 'prefers-reduced-motion', value: 'reduce' }, + ], + }, + }), + ); + mount(); + cy.window().then(view => { + expect(view.matchMedia('(forced-colors: active)').matches).to.equal(true); + expect(view.matchMedia('(prefers-reduced-motion: reduce)').matches).to.equal(true); + }); + cy.get('[role="combobox"]').focus().realPress('ArrowDown'); + cy.get('[role="dialog"]').should('be.visible'); + cy.get('button[aria-label="January 20, 2030"]').click(); + cy.get('[role="combobox"]').should('have.value', new Date(2030, 0, 20).toDateString()); + cy.focused().should('have.attr', 'role', 'combobox'); + }); + + afterEach(() => { + cy.then(() => + Cypress.automation('remote:debugger:protocol', { + command: 'Emulation.setEmulatedMedia', + params: { features: [] }, + }), + ); + }); +}); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx index 60bcff06a3cd9..76de2e916db4d 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx @@ -1,18 +1,550 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { act, fireEvent, render, within } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { Field } from '@fluentui/react-field'; +import { FluentProvider } from '@fluentui/react-provider'; +import { webLightTheme } from '@fluentui/react-theme'; +import { resetIdsForTests } from '@fluentui/react-utilities'; import { isConformant } from '../../testing/isConformant'; import { DatePicker } from './DatePicker'; +import { datePickerClassNames } from './useDatePickerStyles.styles'; +import type { DatePickerProps } from './DatePicker.types'; +import * as publicApi from '../../index'; + +const today = new Date(2030, 0, 15); +const selectedDate = new Date(2030, 0, 20); +expect.extend(toHaveNoViolations); describe('DatePicker', () => { + beforeEach(() => resetIdsForTests()); + + it('exports the component, reusable hooks, renderer, classes, and default error messages', () => { + expect(publicApi.DatePicker).toBe(DatePicker); + expect(publicApi.datePickerClassNames).toBe(datePickerClassNames); + expect(publicApi.useDatePickerBase_unstable).toBeInstanceOf(Function); + expect(publicApi.useDatePicker_unstable).toBeInstanceOf(Function); + expect(publicApi.useDatePickerStyles_unstable).toBeInstanceOf(Function); + expect(publicApi.renderDatePicker_unstable).toBeInstanceOf(Function); + expect(publicApi.defaultDatePickerErrorStrings).toEqual({ + 'invalid-input': 'Invalid date format', + 'out-of-bounds': 'Date is out of bounds', + 'required-input': 'Field is required', + }); + }); + isConformant({ Component: DatePicker, displayName: 'DatePicker', + disabledTests: ['component-has-root-ref'], + testOptions: { + 'has-static-classnames': [ + { + props: { defaultOpen: true, today }, + expectedClassNames: datePickerClassNames, + getPortalElement: result => result.baseElement.querySelector('[role="dialog"]')!, + }, + ], + }, }); - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - - it('renders a default state', () => { - const result = render(Default DatePicker); + it('renders an empty, enabled, selection-only combobox', () => { + const result = render(); + const input = result.getByRole('combobox'); + expect(input.getAttribute('readonly')).not.toBeNull(); + expect(input.getAttribute('disabled')).toBeNull(); + expect(input.getAttribute('aria-expanded')).toBe('false'); + expect(input.getAttribute('aria-controls')).toBeNull(); + expect(input.getAttribute('aria-haspopup')).toBe('dialog'); + expect(result.queryByRole('dialog')).toBeNull(); expect(result.container).toMatchSnapshot(); }); + + it.each([false, true])('has no automated accessibility violations when open=%s', open => { + const result = render( + +
+ + + +
+
, + ); + return expect(axe(result.baseElement)).resolves.toHaveNoViolations(); + }); + + it('forwards the input ref and merges wrapper, Calendar, and picker refs', () => { + const ref = React.createRef(); + const rootRef = React.createRef(); + const calendarRef = jest.fn(); + const dayRef = jest.fn(); + const result = render( + , + ); + expect(ref.current).toBe(result.getByRole('combobox')); + expect(rootRef.current).toBe(ref.current?.parentElement); + fireEvent.click(result.getByRole('combobox')); + expect(calendarRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + expect(dayRef).toHaveBeenCalledWith(expect.objectContaining({ focus: expect.any(Function) })); + }); + + it('routes native input props and inherits Field metadata and required state', () => { + const result = render( + + + , + ); + const input = result.getByRole('combobox', { name: 'Start date' }); + expect(input.getAttribute('name')).toBe('start'); + expect(input.getAttribute('placeholder')).toBe('Select a date'); + expect(input.getAttribute('required')).not.toBeNull(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby')).toBeTruthy(); + }); + + it('does not open on focus by default', () => { + const result = render(); + fireEvent.focus(result.getByRole('combobox')); + expect(result.queryByRole('dialog')).toBeNull(); + }); + + it.each(['Enter', 'ArrowDown'])('opens from %s and focuses Calendar', key => { + const onOpenChange = jest.fn(); + const result = render(); + const input = result.getByRole('combobox'); + fireEvent.keyDown(input, { key }); + const dialog = result.getByRole('dialog', { name: 'Calendar' }); + expect(input.getAttribute('aria-controls')).toBe(dialog.id); + expect(dialog.contains(dialog.ownerDocument.activeElement)).toBe(true); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: 'keydown', open: true, reason: 'keyboard' }), + ); + }); + + it('opens from the icon independently of openOnClick', () => { + const result = render(); + fireEvent.click(result.getByRole('combobox')); + expect(result.queryByRole('dialog')).toBeNull(); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.getByRole('dialog')).toBeTruthy(); + }); + + it('leaves an open selection-only popup open on repeated activation', () => { + const onOpenChange = jest.fn(); + const result = render(); + fireEvent.click(result.getByRole('combobox')); + fireEvent.click(result.getByRole('combobox')); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.getByRole('dialog')).toBeTruthy(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('dismisses an editable popup on repeated trigger activation', () => { + const result = render(); + fireEvent.click(result.getByRole('combobox')); + expect(result.queryByRole('dialog')).toBeNull(); + }); + + it('does not reopen on focus restoration after Escape', () => { + const onOpenChange = jest.fn(); + const result = render(); + act(() => result.getByRole('combobox').focus()); + fireEvent.keyDown(result.getByRole('dialog').ownerDocument.activeElement!, { key: 'Escape' }); + expect(result.queryByRole('dialog')).toBeNull(); + expect(result.getByRole('combobox').ownerDocument.activeElement).toBe(result.getByRole('combobox')); + expect(onOpenChange).toHaveBeenCalledTimes(2); + }); + + it('leaves closed Escape available to surrounding controls', () => { + const onKeyDown = jest.fn(); + const result = render( +
+ +
, + ); + fireEvent.keyDown(result.getByRole('combobox'), { key: 'Escape' }); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onKeyDown.mock.calls[0][0].defaultPrevented).toBe(false); + }); + + it.each(['onClick', 'onKeyDown', 'onFocus'] as const)('honors canceled input %s', handler => { + const result = render( + event.preventDefault() }} />, + ); + const input = result.getByRole('combobox'); + if (handler === 'onClick') { + fireEvent.click(input); + } else if (handler === 'onKeyDown') { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + } else { + fireEvent.focus(input); + } + expect(result.queryByRole('dialog')).toBeNull(); + }); + + it('honors canceled icon activation', () => { + const result = render( event.preventDefault() }} />); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.queryByRole('dialog')).toBeNull(); + }); + + it('commits Calendar selection once and restores input focus', () => { + const onDateChange = jest.fn(); + const onOpenChange = jest.fn(); + const onValidationResult = jest.fn(); + const result = render( + , + ); + fireEvent.click(result.getByRole('button', { name: 'January 20, 2030' })); + expect(result.queryByRole('dialog')).toBeNull(); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe(selectedDate.toDateString()); + expect(onDateChange).toHaveBeenCalledTimes(1); + expect(onDateChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + value: selectedDate, + reason: 'calendar', + selectedDateRange: [selectedDate], + }), + ); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onValidationResult).toHaveBeenCalledTimes(1); + expect(result.getByRole('combobox').ownerDocument.activeElement).toBe(result.getByRole('combobox')); + }); + + it('allows Calendar selection cancellation before committing or closing', () => { + const onDateChange = jest.fn(); + const result = render( + event.preventDefault() }} + />, + ); + fireEvent.click(result.getByRole('button', { name: 'January 20, 2030' })); + expect(result.getByRole('dialog')).toBeTruthy(); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + it.each(['week', 'workWeek', 'month'] as const)('forwards Calendar %s selection ranges', dateRangeType => { + const onDateChange = jest.fn(); + const onSelectDate = jest.fn(); + const result = render( + , + ); + fireEvent.click(result.getByRole('button', { name: 'January 20, 2030' })); + expect(onDateChange.mock.calls[0][1].selectedDateRange).toEqual(onSelectDate.mock.calls[0][1].selectedDateRange); + expect(onDateChange.mock.calls[0][1].selectedDateRange.length).toBeGreaterThan(1); + }); + + it('keeps controlled selection and visibility unchanged when requests are rejected', () => { + const onDateChange = jest.fn(); + const onOpenChange = jest.fn(); + const result = render( + , + ); + fireEvent.click(result.getByRole('button', { name: 'January 20, 2030' })); + expect(result.getByRole('dialog')).toBeTruthy(); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe(''); + expect(onDateChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('updates from external value changes without emitting requests', () => { + const onDateChange = jest.fn(); + const onOpenChange = jest.fn(); + const result = render(); + result.rerender(); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe(today.toDateString()); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('observes draft changes without parsing until blur', () => { + const onChange = jest.fn(); + const parseDate = jest.fn(() => selectedDate); + const onDateChange = jest.fn(); + const result = render( + , + ); + const input = result.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'January 20' } }); + expect(onChange).toHaveBeenCalledWith(expect.anything(), { value: 'January 20' }); + expect(parseDate).not.toHaveBeenCalled(); + expect(onDateChange).not.toHaveBeenCalled(); + fireEvent.blur(input); + expect(parseDate).toHaveBeenCalledTimes(1); + expect(onDateChange.mock.calls[0][1].reason).toBe('input'); + expect((input as HTMLInputElement).value).toBe(selectedDate.toDateString()); + }); + + it('closes for editing without committing the previous draft', () => { + const parseDate = jest.fn(); + const onDateChange = jest.fn(); + const result = render(); + fireEvent.change(result.getByRole('combobox'), { target: { value: 'unfinished' } }); + expect(result.queryByRole('dialog')).toBeNull(); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe('unfinished'); + expect(parseDate).not.toHaveBeenCalled(); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + it.each([ + ['invalid', 'invalid-input', today.toDateString()], + ['January 25, 2030', 'out-of-bounds', 'January 25, 2030'], + ['', 'required-input', ''], + ])('validates editable %s and applies the documented recovery', (text, error, expectedText) => { + const onValidationResult = jest.fn(); + const result = render( + , + ); + const input = result.getByRole('combobox'); + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); + expect(onValidationResult).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ error })); + expect((input as HTMLInputElement).value).toBe(expectedText); + }); + + it('validates once when opening with Enter moves focus off the input', () => { + const onValidationResult = jest.fn(); + const result = render(); + act(() => result.getByRole('combobox').focus()); + fireEvent.keyDown(result.getByRole('combobox'), { key: 'Enter' }); + expect(onValidationResult).toHaveBeenCalledTimes(1); + }); + + it('validates once for outside pointer dismissal followed by input blur', () => { + const onValidationResult = jest.fn(); + const result = render( + <> + + + , + ); + act(() => result.getByRole('combobox').focus()); + fireEvent.pointerDown(result.getByText('Outside')); + act(() => result.getByText('Outside').focus()); + fireEvent.click(result.getByText('Outside')); + expect(result.queryByRole('dialog')).toBeNull(); + expect(onValidationResult).toHaveBeenCalledTimes(1); + expect(result.getByText('Outside').ownerDocument.activeElement).toBe(result.getByText('Outside')); + }); + + it('coalesces pointer and click dismissal when a controlled owner refuses to close', () => { + const onOpenChange = jest.fn(); + const onValidationResult = jest.fn(); + const result = render( + <> + + + , + ); + fireEvent.pointerDown(result.getByText('Outside')); + fireEvent.click(result.getByText('Outside')); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onValidationResult).toHaveBeenCalledTimes(1); + expect(result.getByRole('dialog')).toBeTruthy(); + }); + + it('combines the Field label and localized action name on the icon', () => { + const result = render( + + + , + ); + expect(result.getByRole('button', { name: 'Appointment Choose date' })).toBeTruthy(); + }); + + it('honors Calendar close-action cancellation and restores focus when accepted', () => { + const onDismiss = jest.fn((event: React.SyntheticEvent | Event) => event.preventDefault()); + const calendar = { dayPicker: { closeButton: { 'aria-label': 'Close calendar' } }, onDismiss }; + const result = render(); + fireEvent.click(result.getByRole('button', { name: 'Close calendar' })); + expect(result.getByRole('dialog')).toBeTruthy(); + result.rerender(); + fireEvent.click(result.getByRole('button', { name: 'Close calendar' })); + expect(result.queryByRole('dialog')).toBeNull(); + expect(result.getByRole('combobox').ownerDocument.activeElement).toBe(result.getByRole('combobox')); + }); + + it('does not steal outside focus on an external controlled close', () => { + const result = render( + <> + + + , + ); + act(() => result.getByText('Outside').focus()); + result.rerender( + <> + + + , + ); + expect(result.getByText('Outside').ownerDocument.activeElement).toBe(result.getByText('Outside')); + }); + + it('keeps nested input props from bypassing disabled and Field required state', () => { + const result = render( + + + , + ); + const input = result.getByRole('combobox') as HTMLInputElement; + expect(input.disabled).toBe(true); + expect(input.required).toBe(true); + }); + + it('dismisses on outside scroll but not inside scroll', () => { + const result = render(); + fireEvent.scroll(result.getByRole('dialog')); + expect(result.getByRole('dialog')).toBeTruthy(); + fireEvent.scroll(result.container); + expect(result.queryByRole('dialog')).toBeNull(); + }); + + it('does not expose a popup or activate when disabled even if controlled open', () => { + const onOpenChange = jest.fn(); + const result = render(); + fireEvent.click(result.getByRole('combobox')); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + fireEvent.keyDown(result.getByRole('combobox'), { key: 'ArrowDown' }); + expect(result.queryByRole('dialog')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('disables an open popup without synthetic validation or change events', () => { + const onDateChange = jest.fn(); + const onOpenChange = jest.fn(); + const onValidationResult = jest.fn(); + const props = { defaultOpen: true, onDateChange, onOpenChange, onValidationResult }; + const result = render(); + result.rerender(); + expect(result.queryByRole('dialog')).toBeNull(); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onValidationResult).not.toHaveBeenCalled(); + }); + + it('supports inline and custom portal placement', () => { + const mountNode = document.createElement('div'); + document.body.appendChild(mountNode); + const result = render(); + expect(within(mountNode).getByRole('dialog')).toBeTruthy(); + result.rerender(); + expect(result.container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(within(mountNode).queryByRole('dialog')).toBeNull(); + result.unmount(); + mountNode.remove(); + }); + + it('focuses the surface when no picker or action is available', () => { + const result = render( + , + ); + const dialog = result.getByRole('dialog'); + expect(dialog.ownerDocument.activeElement).toBe(dialog); + }); + + it('uses the provider document for portals, focus, and dismissal', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const targetDocument = iframe.contentDocument!; + const container = targetDocument.createElement('div'); + targetDocument.body.appendChild(container); + const result = render( + + + , + { container, baseElement: targetDocument.body }, + ); + const dialog = result.getByRole('dialog'); + expect(dialog.ownerDocument).toBe(targetDocument); + expect(dialog.contains(targetDocument.activeElement)).toBe(true); + fireEvent.pointerDown(targetDocument.body); + expect(result.queryByRole('dialog')).toBeNull(); + result.unmount(); + iframe.remove(); + }); + + it.each([false, true])('renders on the server with defaultOpen=%s', defaultOpen => { + expect(() => renderToString()).not.toThrow(); + }); + + it('does not allow nested Input state to override DatePicker ownership', () => { + const input: DatePickerProps['input'] = { value: 'wrong', defaultValue: 'wrong', type: 'date', readOnly: false }; + const result = render(); + const nativeInput = result.getByRole('combobox') as HTMLInputElement; + expect(nativeInput.value).toBe(today.toDateString()); + expect(nativeInput.type).toBe('text'); + expect(nativeInput.readOnly).toBe(true); + }); + + it('composes native and Input change observers and respects cancellation', () => { + const onChange = jest.fn(); + const onNativeChange = jest.fn((event: React.ChangeEvent) => event.preventDefault()); + const result = render(); + fireEvent.change(result.getByRole('combobox'), { target: { value: 'unfinished' } }); + expect(onNativeChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe(''); + }); + + it('allows blur validation cancellation', () => { + const onValidationResult = jest.fn(); + const result = render( + event.preventDefault() }} + />, + ); + fireEvent.change(result.getByRole('combobox'), { target: { value: 'invalid' } }); + fireEvent.blur(result.getByRole('combobox')); + expect(onValidationResult).not.toHaveBeenCalled(); + expect((result.getByRole('combobox') as HTMLInputElement).value).toBe('invalid'); + }); + + it('dismisses and restores focus for an external wheel interaction', () => { + const result = render(); + fireEvent.wheel(result.getByRole('dialog')); + expect(result.getByRole('dialog')).toBeTruthy(); + fireEvent.wheel(result.container); + expect(result.queryByRole('dialog')).toBeNull(); + expect(result.getByRole('combobox').ownerDocument.activeElement).toBe(result.getByRole('combobox')); + }); + + it('composes surface focus and key events and handles Escape without Calendar', () => { + const onFocusCapture = jest.fn(); + const onBlurCapture = jest.fn(); + const onKeyDown = jest.fn(); + const result = render( + , + ); + expect(onFocusCapture).toHaveBeenCalled(); + act(() => result.getByRole('combobox').focus()); + expect(onBlurCapture).toHaveBeenCalled(); + fireEvent.keyDown(result.getByRole('dialog'), { key: 'Escape' }); + expect(onKeyDown).toHaveBeenCalled(); + expect(result.queryByRole('dialog')).toBeNull(); + }); }); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx index e283a2da05fbc..053feb49c373d 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx @@ -7,23 +7,13 @@ import { useDatePickerStyles_unstable } from './useDatePickerStyles.styles'; import type { DatePickerProps } from './DatePicker.types'; /** - * DatePicker component - TODO: add more docs + * A date input with an accessible Calendar Preview popup. */ export const DatePicker: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useDatePicker_unstable(props, ref); useDatePickerStyles_unstable(state); - /** - * @see https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/react-components/convergence/custom-styling.md - * - * TODO: 💡 once package will become stable (PR which will be part of promoting PREVIEW package to STABLE), - * - uncomment this line - * - update types {@link file://./../../../../../../../packages/react-components/react-shared-contexts/library/src/CustomStyleHooksContext/CustomStyleHooksContext.ts#CustomStyleHooksContextValue} - * - verify that custom global style override works for your component - */ - // useCustomStyleHook_unstable('useDatePickerStyles_unstable')(state); - return renderDatePicker_unstable(state); }); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.test.tsx new file mode 100644 index 0000000000000..9553a13588865 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { DatePicker } from '../../index'; +import type { DatePickerOpenChangeData, DatePickerProps } from '../../index'; + +describe('DatePicker public types', () => { + it('accepts native input refs, string unions, slots, and event/data handlers', () => { + const inputRef = React.createRef(); + const calendarRef = React.createRef(); + const props: DatePickerProps = { + value: null, + size: 'small', + appearance: 'filled-darker', + calendar: { ref: calendarRef, firstDayOfWeek: 'monday', dateRangeType: 'week', layout: 'overlay' }, + onDateChange: (event, data) => { + const date: Date | null = data.value; + const range: Date[] = data.selectedDateRange; + const reason: 'calendar' | 'input' | 'clear' = data.reason; + expect([event, date, range, reason]).toBeDefined(); + }, + onValidationResult: (event, data) => { + const error: 'invalid-input' | 'out-of-bounds' | 'required-input' | undefined = data.error; + expect([event, error]).toBeDefined(); + }, + onOpenChange: (event, data) => { + const open: boolean = data.open; + const reason: DatePickerOpenChangeData['reason'] = data.reason; + expect([event, open, reason]).toBeDefined(); + }, + }; + expect().toBeTruthy(); + }); + + it('rejects string selection and invalid string unions', () => { + // @ts-expect-error DatePicker selection is a date, not Input text. + const value: DatePickerProps = { value: '2030-01-15' }; + // @ts-expect-error DatePicker owns the native input type. + const type: DatePickerProps = { type: 'date' }; + // @ts-expect-error Editable mode is configured with allowTextInput. + const readOnly: DatePickerProps = { readOnly: false }; + // @ts-expect-error V9 sizes are string unions. + const size: DatePickerProps = { size: 'compact' }; + // @ts-expect-error Calendar weekdays are string unions, not legacy numeric enums. + const weekday: DatePickerProps = { calendar: { firstDayOfWeek: 1 } }; + expect([value, type, readOnly, size, weekday]).toHaveLength(5); + }); + + it('reserves shared Calendar selection, boundaries, and reference date', () => { + // @ts-expect-error Selection belongs on DatePicker. + const value: DatePickerProps = { calendar: { value: null } }; + // @ts-expect-error Default selection belongs on DatePicker. + const defaultValue: DatePickerProps = { calendar: { defaultValue: null } }; + // @ts-expect-error The reference date belongs on DatePicker. + const today: DatePickerProps = { calendar: { today: new Date() } }; + // @ts-expect-error The inclusive lower boundary belongs on DatePicker. + const minDate: DatePickerProps = { calendar: { minDate: new Date() } }; + // @ts-expect-error The inclusive upper boundary belongs on DatePicker. + const maxDate: DatePickerProps = { calendar: { maxDate: new Date() } }; + expect([value, defaultValue, today, minDate, maxDate]).toHaveLength(5); + }); + + it('rejects a wrapper ref where a native input ref is required', () => { + const ref = React.createRef(); + // @ts-expect-error The forwarded ref targets HTMLInputElement, not the wrapper. + const element = ; + expect(element).toBeTruthy(); + }); +}); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts index a140322c30934..9a0f69b4c23b6 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.ts @@ -1,17 +1,86 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type * as React from 'react'; +import type { Calendar, CalendarProps } from '@fluentui/react-calendar-preview'; +import type { Input } from '@fluentui/react-input'; +import type { PortalProps } from '@fluentui/react-portal'; +import type { PositioningProps } from '@fluentui/react-positioning'; +import type { + ComponentProps, + ComponentState, + EventData, + EventHandler, + RefAttributes, + Slot, +} from '@fluentui/react-utilities'; +import type { DatePickerDateState, DatePickerDateStateOptions } from './useDatePickerDateState'; + +export type { + DatePickerDateChangeData, + DatePickerErrorType, + DatePickerValidationResultData, +} from './useDatePickerDateState'; export type DatePickerSlots = { - root: Slot<'div'>; + /** Input wrapper. The forwarded DatePicker ref targets the native input. */ + root: NonNullable>; + /** Calendar configuration; selection and shared boundaries are owned by DatePicker. */ + calendar: NonNullable>; + /** Named dialog, mounted only while open. */ + popupSurface?: Slot<'div'>; +}; + +/** Data reported for a requested popup visibility transition. */ +export type DatePickerOpenChangeData = EventData & { + open: boolean; + reason: 'input' | 'icon' | 'keyboard' | 'focus' | 'selection' | 'dismiss'; }; -/** - * DatePicker Props - */ -export type DatePickerProps = ComponentProps & {}; +/** A date input with a dismissible Calendar Preview dialog. */ +export type DatePickerProps = Omit< + ComponentProps>, + 'value' | 'defaultValue' | 'type' | 'readOnly' | 'calendar' +> & + Omit & { + /** Calendar-only configuration. Shared selection and date boundaries belong on DatePicker. */ + calendar?: NonNullable< + Slot< + Omit & RefAttributes + > + >; + /** Controlled popup visibility. */ + open?: boolean; + /** Initial uncontrolled popup visibility. @default false */ + defaultOpen?: boolean; + onOpenChange?: EventHandler; + /** Open from the input body; the icon and keyboard remain available. @default true */ + openOnClick?: boolean; + /** Open on focus in selection-only mode. @default false */ + openOnFocus?: boolean; + /** Reference date shared with Calendar; does not select a date. */ + today?: Date; + /** Inclusive local-date lower boundary. */ + minDate?: Date; + /** Inclusive local-date upper boundary. */ + maxDate?: Date; + /** Render beside the input instead of in a Portal. @default false */ + inlinePopup?: boolean; + mountNode?: PortalProps['mountNode']; + positioning?: PositioningProps; + }; -/** - * State used in rendering DatePicker - */ -export type DatePickerState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from DatePickerProps. -// & Required> +/** Props for behavior without DatePicker styling defaults. */ +export type DatePickerBaseProps = Omit; + +/** State used in rendering DatePicker. */ +export type DatePickerState = ComponentState & + Required> & + Pick & { + open: boolean; + }; + +/** Behavior shared by styled and custom-design-system integrations, without Tabster or visual slots. */ +export type DatePickerBaseState = DatePickerDateState & { + open: boolean; + today: Date; + required?: boolean; + requestOpen: (event: React.SyntheticEvent | Event, open: boolean, reason: DatePickerOpenChangeData['reason']) => void; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap index e0db7d1cbf9a4..5cb41f8ea34aa 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/__snapshots__/DatePicker.test.tsx.snap @@ -1,11 +1,43 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`DatePicker renders a default state 1`] = ` +exports[`DatePicker renders an empty, enabled, selection-only combobox 1`] = `
-
- Default DatePicker -
+ + +
`; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/defaults.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/defaults.ts new file mode 100644 index 0000000000000..f81d8483fb6de --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/defaults.ts @@ -0,0 +1,8 @@ +import type { DatePickerErrorType } from './DatePicker.types'; + +/** Default validation messages. Applications may supply localized messages to Field. */ +export const defaultDatePickerErrorStrings: Record = { + 'invalid-input': 'Invalid date format', + 'out-of-bounds': 'Date is out of bounds', + 'required-input': 'Field is required', +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts index 11adf7404e60d..6c770f94d33ec 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/index.ts @@ -1,5 +1,17 @@ export { DatePicker } from './DatePicker'; -export type { DatePickerProps, DatePickerState } from './DatePicker.types'; +export type { + DatePickerBaseProps, + DatePickerBaseState, + DatePickerDateChangeData, + DatePickerErrorType, + DatePickerOpenChangeData, + DatePickerProps, + DatePickerSlots, + DatePickerState, + DatePickerValidationResultData, +} from './DatePicker.types'; +export { defaultDatePickerErrorStrings } from './defaults'; export { renderDatePicker_unstable } from './renderDatePicker'; export { useDatePicker_unstable } from './useDatePicker'; +export { useDatePickerBase_unstable } from './useDatePickerBase'; export { datePickerClassNames, useDatePickerStyles_unstable } from './useDatePickerStyles.styles'; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx index 24b75a9f1b1ce..226513a6aaab5 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/renderDatePicker.tsx @@ -2,6 +2,7 @@ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { assertSlots } from '@fluentui/react-utilities'; +import { Portal } from '@fluentui/react-portal'; import type { JSXElement } from '@fluentui/react-utilities'; import type { DatePickerState, DatePickerSlots } from './DatePicker.types'; @@ -11,6 +12,16 @@ import type { DatePickerState, DatePickerSlots } from './DatePicker.types'; export const renderDatePicker_unstable = (state: DatePickerState): JSXElement => { assertSlots(state); - // TODO Add additional slots in the appropriate place - return ; + const popup = state.popupSurface ? ( + + + + ) : null; + + return ( + <> + + {state.inlinePopup ? popup : popup && {popup}} + + ); }; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts deleted file mode 100644 index 9c5851e42033e..0000000000000 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { DatePickerProps, DatePickerState } from './DatePicker.types'; - -/** - * Create the state required to render DatePicker. - * - * The returned state can be modified with hooks such as useDatePickerStyles_unstable, - * before being passed to renderDatePicker_unstable. - * - * @param props - props from this instance of DatePicker - * @param ref - reference to root HTMLDivElement of DatePicker - */ -export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref): DatePickerState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add each slot's element type or component - root: 'div', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: slot.always( - getIntrinsicElementProps('div', { - ref, - ...props, - }), - { elementType: 'div' }, - ), - }; -}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx new file mode 100644 index 0000000000000..bcc9b7674baf4 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx @@ -0,0 +1,364 @@ +'use client'; + +import * as React from 'react'; +import { Calendar, CalendarDay, CalendarMonth } from '@fluentui/react-calendar-preview'; +import type { CalendarDayHandle, CalendarMonthHandle } from '@fluentui/react-calendar-preview'; +import { useFieldContext_unstable } from '@fluentui/react-field'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; +import { Input } from '@fluentui/react-input'; +import type { InputProps } from '@fluentui/react-input'; +import { resolvePositioningShorthand, usePositioning } from '@fluentui/react-positioning'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { useFocusFinders, useModalAttributes } from '@fluentui/react-tabster'; +import { + slot, + useEventCallback, + useId, + useIsSSR, + useMergedRefs, + useOnClickOutside, + useOnScrollOutside, +} from '@fluentui/react-utilities'; +import { useDatePickerBase_unstable } from './useDatePickerBase'; +import type { DatePickerProps, DatePickerState } from './DatePicker.types'; + +/** Manages selection, editable text, popup lifecycle, focus, and composed slot events. */ +export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref): DatePickerState => { + const dateState = useDatePickerBase_unstable(props); + const { open, requestOpen, today, required } = dateState; + const { + value, + defaultValue, + allowTextInput = false, + disabled = false, + required: requiredProp, + formatDate, + parseDate, + onDateChange, + onValidationResult, + open: openProp, + defaultOpen, + onOpenChange, + openOnClick = true, + openOnFocus = false, + today: todayProp, + minDate, + maxDate, + inlinePopup = false, + mountNode, + positioning, + calendar: calendarProp, + popupSurface: popupSurfaceProp, + ...inputProps + } = props; + const fieldContext = useFieldContext_unstable(); + const isSSR = useIsSSR(); + const popupOpen = open && (inlinePopup || !isSSR); + const { targetDocument } = useFluent_unstable(); + const inputRef = React.useRef(null); + const surfaceRef = React.useRef(null); + const dayRef = React.useRef(null); + const monthRef = React.useRef(null); + const suppressFocusOpening = React.useRef(false); + const restoreFocus = React.useRef(undefined); + const focusWithinPopup = React.useRef(false); + const skipBlurValidation = React.useRef(false); + const lastCloseEvent = React.useRef(undefined); + const outsidePointerTarget = React.useRef(null); + const inputId = useId('datePicker-input', props.id ?? props.input?.id); + const popupId = useId('datePicker-popup', slot.resolveShorthand(popupSurfaceProp)?.id); + const iconId = useId('datePicker-icon'); + const { targetRef, containerRef } = usePositioning({ + position: 'below', + align: 'start', + ...resolvePositioningShorthand(positioning), + }); + const { findFirstFocusable } = useFocusFinders(); + const { modalAttributes } = useModalAttributes({ trapFocus: true, alwaysFocusable: true, legacyTrapFocus: true }); + const calendar = slot.always>(calendarProp, { elementType: Calendar }); + const dismiss = useEventCallback((event: React.SyntheticEvent | Event, returnFocus: boolean) => { + const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event; + if (!open || lastCloseEvent.current === nativeEvent) { + return; + } + lastCloseEvent.current = nativeEvent; + restoreFocus.current = returnFocus; + dateState.validate(event); + requestOpen(event, false, 'dismiss'); + }); + const activate = useEventCallback((event: React.SyntheticEvent, reason: 'input' | 'icon' | 'focus') => { + if (event.defaultPrevented || disabled) { + return; + } + if (open) { + if (allowTextInput) { + dismiss(event, true); + } + } else { + requestOpen(event, true, reason); + } + }); + + const input = slot.always(props.input, { elementType: 'input' }); + input.ref = useMergedRefs(input.ref, ref, inputRef); + const inputRoot = slot.always(props.root, { elementType: 'span' }); + inputRoot.ref = useMergedRefs(inputRoot.ref, targetRef); + const contentAfter = slot.always(props.contentAfter ?? {}, { + elementType: 'span', + defaultProps: { children: }, + }); + const consumerIconClick = contentAfter.onClick; + contentAfter.onClick = useEventCallback(event => { + consumerIconClick?.(event); + if (!event.defaultPrevented) { + activate(event, 'icon'); + event.preventDefault(); + } + event.stopPropagation(); + }); + contentAfter.role = 'button'; + contentAfter.tabIndex = undefined; + const inputLabel = input['aria-label'] ?? props['aria-label']; + const inputLabelledBy = input['aria-labelledby'] ?? props['aria-labelledby'] ?? fieldContext?.labelId; + const iconLabel = contentAfter['aria-label'] ?? 'Open calendar'; + contentAfter.id ??= iconId; + contentAfter['aria-label'] = inputLabel && !inputLabelledBy ? `${inputLabel} ${iconLabel}` : iconLabel; + contentAfter['aria-labelledby'] ??= inputLabelledBy ? `${inputLabelledBy} ${contentAfter.id}` : undefined; + contentAfter['aria-expanded'] = open; + contentAfter['aria-disabled'] = disabled || undefined; + contentAfter['aria-controls'] = open ? popupId : undefined; + + const root = slot.always(inputProps, { elementType: Input }); + root.appearance = props.appearance ?? 'outline'; + root.size = props.size ?? fieldContext?.size ?? 'medium'; + root.root = inputRoot; + root.input = input; + root.contentAfter = contentAfter; + root.required = required; + root.disabled = disabled; + input.required = required; + input.disabled = disabled; + root.id = inputId; + root.value = dateState.text; + root.defaultValue = undefined; + root.type = input.type = 'text'; + root.readOnly = input.readOnly = !allowTextInput; + input.value = dateState.text; + input.defaultValue = undefined; + input.role = 'combobox'; + input['aria-haspopup'] = 'dialog'; + input['aria-expanded'] = open; + input['aria-controls'] = open ? popupId : undefined; + + const consumerChange = root.onChange; + const consumerNativeChange = input.onChange; + // Input routes its handler through input.onChange unless that slot supplies one. + input.onChange = undefined; + root.onChange = useEventCallback((event, data) => { + consumerNativeChange?.(event); + consumerChange?.(event, data); + if (!event.defaultPrevented && allowTextInput && !disabled) { + dateState.setText(data.value); + requestOpen(event, false, 'input'); + } + }); + const consumerClick = input.onClick ?? root.onClick; + input.onClick = useEventCallback(event => { + consumerClick?.(event); + if (openOnClick) { + activate(event, 'input'); + } + }); + const consumerFocus = input.onFocus ?? root.onFocus; + input.onFocus = useEventCallback(event => { + consumerFocus?.(event); + skipBlurValidation.current = false; + if (suppressFocusOpening.current) { + suppressFocusOpening.current = false; + } else if (openOnFocus && !allowTextInput) { + activate(event, 'focus'); + } + }); + const consumerBlur = input.onBlur ?? root.onBlur; + input.onBlur = useEventCallback(event => { + consumerBlur?.(event); + if (!event.defaultPrevented && !open && !skipBlurValidation.current) { + dateState.validate(event); + } + skipBlurValidation.current = false; + }); + const consumerKeyDown = input.onKeyDown ?? root.onKeyDown; + input.onKeyDown = useEventCallback(event => { + consumerKeyDown?.(event); + if (event.defaultPrevented || disabled) { + return; + } + if (event.key === 'Enter') { + if (!open) { + dateState.validate(event); + requestOpen(event, true, 'keyboard'); + } else if (allowTextInput) { + dismiss(event, true); + } + } else if (event.key === 'ArrowDown') { + requestOpen(event, true, 'keyboard'); + } else if (event.key === 'Escape' && open) { + dismiss(event, true); + } else { + return; + } + event.preventDefault(); + event.stopPropagation(); + }); + + const consumerSelect = calendar.onSelectDate; + calendar.onSelectDate = useEventCallback((event, data) => { + consumerSelect?.(event, data); + if (!event.defaultPrevented && !disabled) { + dateState.selectDate(event, data.date, data.selectedDateRange); + restoreFocus.current = true; + requestOpen(event, false, 'selection'); + } + }); + const consumerDismiss = calendar.onDismiss; + calendar.onDismiss = useEventCallback((event, data) => { + consumerDismiss?.(event, data); + if (!event.defaultPrevented) { + dismiss(event, true); + event.preventDefault(); + } + }); + calendar.value = dateState.value && Number.isFinite(dateState.value.getTime()) ? dateState.value : null; + calendar.today = today; + calendar.minDate = minDate; + calendar.maxDate = maxDate; + const dayPicker = slot.optional(calendar.dayPicker, { elementType: CalendarDay, renderByDefault: true }); + const monthPicker = slot.optional(calendar.monthPicker, { elementType: CalendarMonth, renderByDefault: true }); + const mergedDayRef = useMergedRefs(dayPicker?.ref, dayRef); + const mergedMonthRef = useMergedRefs(monthPicker?.ref, monthRef); + if (dayPicker) { + dayPicker.ref = mergedDayRef; + calendar.dayPicker = dayPicker; + } + if (monthPicker) { + monthPicker.ref = mergedMonthRef; + calendar.monthPicker = monthPicker; + } + + const popupSurface = slot.optional(popupSurfaceProp, { + elementType: 'div', + renderByDefault: true, + defaultProps: { 'aria-label': 'Calendar', tabIndex: -1 }, + }); + const mergedSurfaceRef = useMergedRefs(popupSurface?.ref, surfaceRef, containerRef); + if (popupSurface) { + popupSurface.ref = mergedSurfaceRef; + popupSurface.id = popupId; + popupSurface.role = 'dialog'; + popupSurface['aria-modal'] = true; + Object.assign(popupSurface, modalAttributes); + } + const consumerSurfaceKeyDown = popupSurface?.onKeyDown; + const consumerSurfaceFocus = popupSurface?.onFocusCapture; + const consumerSurfaceBlur = popupSurface?.onBlurCapture; + const onSurfaceFocus = useEventCallback((event: React.FocusEvent) => { + consumerSurfaceFocus?.(event); + focusWithinPopup.current = true; + }); + const onSurfaceBlur = useEventCallback((event: React.FocusEvent) => { + consumerSurfaceBlur?.(event); + focusWithinPopup.current = event.currentTarget.contains(event.relatedTarget); + }); + const onSurfaceKeyDown = useEventCallback((event: React.KeyboardEvent) => { + consumerSurfaceKeyDown?.(event); + if (!event.defaultPrevented && event.key === 'Escape') { + dismiss(event, true); + event.preventDefault(); + event.stopPropagation(); + } + }); + if (popupSurface) { + popupSurface.onKeyDown = onSurfaceKeyDown; + popupSurface.onFocusCapture = onSurfaceFocus; + popupSurface.onBlurCapture = onSurfaceBlur; + } + + const outside = useEventCallback((event: Event) => { + if (outsidePointerTarget.current === event.target) { + if (event.type === 'click') { + outsidePointerTarget.current = null; + } + return; + } + dismiss(event, false); + }); + useOnClickOutside({ + element: targetDocument, + refs: [targetRef, surfaceRef], + disabled: !popupOpen, + callback: outside, + }); + useOnScrollOutside({ + element: targetDocument, + refs: [targetRef, surfaceRef], + disabled: !popupOpen, + callback: event => dismiss(event, !!surfaceRef.current?.contains(targetDocument?.activeElement ?? null)), + }); + const onOutsidePointer = useEventCallback((event: PointerEvent) => { + outsidePointerTarget.current = null; + if (!event.composedPath().includes(targetRef.current!) && !event.composedPath().includes(surfaceRef.current!)) { + skipBlurValidation.current = targetDocument?.activeElement === inputRef.current; + dismiss(event, false); + outsidePointerTarget.current = event.target; + } + }); + const onOutsideScroll = useEventCallback((event: Event) => { + if (!event.composedPath().includes(targetRef.current!) && !event.composedPath().includes(surfaceRef.current!)) { + dismiss(event, !!surfaceRef.current?.contains(targetDocument?.activeElement ?? null)); + } + }); + React.useEffect(() => { + if (!popupOpen) { + return; + } + targetDocument?.addEventListener('pointerdown', onOutsidePointer, true); + targetDocument?.addEventListener('scroll', onOutsideScroll, true); + return () => { + targetDocument?.removeEventListener('pointerdown', onOutsidePointer, true); + targetDocument?.removeEventListener('scroll', onOutsideScroll, true); + }; + }, [popupOpen, targetDocument, onOutsidePointer, onOutsideScroll]); + + const wasOpen = React.useRef(false); + React.useEffect(() => { + if (popupOpen && !wasOpen.current) { + dayRef.current?.focus({ preventScroll: true }); + if (!surfaceRef.current?.contains(targetDocument?.activeElement ?? null)) { + monthRef.current?.focus({ preventScroll: true }); + } + if (!surfaceRef.current?.contains(targetDocument?.activeElement ?? null)) { + (findFirstFocusable(surfaceRef.current) ?? surfaceRef.current)?.focus({ preventScroll: true }); + } + focusWithinPopup.current = !!surfaceRef.current?.contains(targetDocument?.activeElement ?? null); + } else if (!popupOpen && wasOpen.current && !disabled && (restoreFocus.current ?? focusWithinPopup.current)) { + if (inputRef.current && targetDocument?.activeElement !== inputRef.current) { + suppressFocusOpening.current = true; + inputRef.current.focus({ preventScroll: true }); + } + } + wasOpen.current = popupOpen; + restoreFocus.current = undefined; + }, [popupOpen, disabled, targetDocument, findFirstFocusable]); + + return { + components: { root: Input, calendar: Calendar, popupSurface: 'div' }, + root, + calendar, + popupSurface: popupOpen ? popupSurface : undefined, + open, + allowTextInput, + disabled, + inlinePopup, + mountNode, + }; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.test.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.test.ts new file mode 100644 index 0000000000000..625c1ed989ee2 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.test.ts @@ -0,0 +1,136 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDatePickerBase_unstable } from './useDatePickerBase'; +import type { DatePickerBaseProps } from './DatePicker.types'; + +describe('useDatePickerBase_unstable', () => { + const today = new Date(2030, 0, 15); + + it('starts empty and closed without emitting events', () => { + const onOpenChange = jest.fn(); + const onDateChange = jest.fn(); + const { result } = renderHook(() => useDatePickerBase_unstable({ today, onOpenChange, onDateChange })); + expect(result.current.value).toBeNull(); + expect(result.current.text).toBe(''); + expect(result.current.open).toBe(false); + expect(result.current.today).toBe(today); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + it('supports uncontrolled visibility and event/data requests', () => { + const onOpenChange = jest.fn(); + const { result } = renderHook(() => useDatePickerBase_unstable({ onOpenChange })); + const event = new Event('keydown'); + act(() => result.current.requestOpen(event, true, 'keyboard')); + expect(result.current.open).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(event, { event, type: 'keydown', open: true, reason: 'keyboard' }); + act(() => result.current.requestOpen(new Event('click'), false, 'dismiss')); + expect(result.current.open).toBe(false); + }); + + it('reports controlled requests without changing controlled visibility', () => { + const onOpenChange = jest.fn(); + const { result, rerender } = renderHook((props: DatePickerBaseProps) => useDatePickerBase_unstable(props), { + initialProps: { open: false, onOpenChange }, + }); + act(() => result.current.requestOpen(new Event('click'), true, 'icon')); + expect(result.current.open).toBe(false); + expect(onOpenChange).toHaveBeenCalledTimes(1); + rerender({ open: true, onOpenChange }); + expect(result.current.open).toBe(true); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('deduplicates visibility requests for one originating event', () => { + const onOpenChange = jest.fn(); + const { result } = renderHook(() => useDatePickerBase_unstable({ open: true, onOpenChange })); + const event = new Event('click'); + act(() => { + result.current.requestOpen(event, false, 'selection'); + result.current.requestOpen(event, false, 'dismiss'); + }); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('suppresses controlled open and user changes while disabled', () => { + const onOpenChange = jest.fn(); + const { result } = renderHook(() => useDatePickerBase_unstable({ disabled: true, open: true, onOpenChange })); + expect(result.current.open).toBe(false); + act(() => result.current.requestOpen(new Event('click'), true, 'input')); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('checks inclusive local date boundaries without clamping supplied selection', () => { + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerBase_unstable({ + allowTextInput: true, + defaultValue: today, + minDate: new Date(2030, 0, 15, 23), + maxDate: new Date(2030, 0, 15, 1), + onValidationResult, + }), + ); + act(() => result.current.validate(new Event('blur'))); + expect(result.current.value).toBe(today); + expect(onValidationResult.mock.calls[0][1].error).toBeUndefined(); + act(() => result.current.setText('January 16, 2030')); + act(() => result.current.validate(new Event('blur'))); + expect(result.current.value).toBe(today); + expect(onValidationResult.mock.calls[1][1].error).toBe('out-of-bounds'); + }); + + it('rejects text and selection on restricted local dates', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const { result } = renderHook(() => + useDatePickerBase_unstable({ + allowTextInput: true, + calendar: { restrictedDates: [new Date(2030, 0, 15, 23)] }, + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.setText('January 15, 2030')); + act(() => result.current.validate(new Event('blur'))); + act(() => result.current.selectDate(new Event('click'), today, [today])); + expect(onDateChange).not.toHaveBeenCalled(); + expect(onValidationResult).toHaveBeenCalledTimes(2); + expect(onValidationResult.mock.calls.every(([, data]) => data.error === 'out-of-bounds')).toBe(true); + }); + + it.each([ + [2032, 1, 29], + [2030, 11, 31], + [2031, 0, 1], + [2030, 2, 10], + [2030, 10, 3], + [2030, 2, 31], + [2030, 9, 27], + ])('compares local day boundaries across calendar transitions: %i-%i-%i', (year, month, day) => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const date = new Date(year, month, day, 12); + const { result } = renderHook(() => + useDatePickerBase_unstable({ + minDate: new Date(year, month, day, 23, 59), + maxDate: new Date(year, month, day, 0, 1), + onDateChange, + onValidationResult, + }), + ); + act(() => result.current.selectDate(new Event('click'), date, [date])); + expect(result.current.value).toBe(date); + expect(onDateChange).toHaveBeenCalledTimes(1); + for (const offset of [-1, 1]) { + const unavailable = new Date(year, month, day + offset, 12); + act(() => result.current.selectDate(new Event('click'), unavailable, [unavailable])); + expect(result.current.value).toBe(date); + expect(onValidationResult).toHaveBeenLastCalledWith( + expect.any(Event), + expect.objectContaining({ error: 'out-of-bounds' }), + ); + } + expect(onDateChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.ts new file mode 100644 index 0000000000000..1998ce7775c47 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerBase.ts @@ -0,0 +1,48 @@ +'use client'; + +import * as React from 'react'; +import { useFieldContext_unstable } from '@fluentui/react-field'; +import { slot, useControllableState, useEventCallback } from '@fluentui/react-utilities'; +import { useDatePickerDateState } from './useDatePickerDateState'; +import type { DatePickerBaseProps, DatePickerBaseState, DatePickerOpenChangeData } from './DatePicker.types'; + +/** Owns date input and popup requests without positioning, visual components, or a focus runtime. */ +export const useDatePickerBase_unstable = (props: DatePickerBaseProps): DatePickerBaseState => { + const required = useFieldContext_unstable()?.required ?? props.required; + const today = React.useMemo(() => props.today ?? new Date(), [props.today]); + const calendar = slot.resolveShorthand(props.calendar); + const [openState, setOpen] = useControllableState({ + state: props.open, + defaultState: props.defaultOpen, + initialState: false, + }); + const open = openState && !props.disabled; + const lastOpenEvent = React.useRef(undefined); + const dateState = useDatePickerDateState({ + ...props, + required, + isDateUnavailable: date => { + // Compare local calendar parts, never timestamps or UTC-normalized dates. + const datePart = (candidate: Date) => + candidate.getFullYear() * 10000 + candidate.getMonth() * 100 + candidate.getDate(); + const part = datePart(date); + return ( + (!!props.minDate && part < datePart(props.minDate)) || + (!!props.maxDate && part > datePart(props.maxDate)) || + !!calendar?.restrictedDates?.some(restricted => datePart(restricted) === part) + ); + }, + }); + const requestOpen = useEventCallback( + (event: React.SyntheticEvent | Event, nextOpen: boolean, reason: DatePickerOpenChangeData['reason']) => { + const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event; + if (props.disabled || nextOpen === open || lastOpenEvent.current === nativeEvent) { + return; + } + lastOpenEvent.current = nativeEvent; + setOpen(nextOpen); + props.onOpenChange?.(event, { type: event.type, event, open: nextOpen, reason }); + }, + ); + return { ...dateState, open, requestOpen, today, required }; +}; diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts index 3bdfdbb8db3f5..491ae33a89f9d 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts @@ -1,12 +1,14 @@ 'use client'; -import { makeStyles, mergeClasses } from '@griffel/react'; +import { makeResetStyles, makeStyles, mergeClasses } from '@griffel/react'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { DatePickerSlots, DatePickerState } from './DatePicker.types'; export const datePickerClassNames: SlotClassNames = { root: 'fui-DatePicker', - // TODO: add class names for all slots on DatePickerSlots. - // Should be of the form `: 'fui-DatePicker__` + calendar: 'fui-DatePicker__calendar', + popupSurface: 'fui-DatePicker__popupSurface', }; /** @@ -14,10 +16,34 @@ export const datePickerClassNames: SlotClassNames = { */ const useStyles = makeStyles({ root: { - // TODO Add default styles for the root element + position: 'relative', }, + selectionOnly: { + cursor: 'pointer', + '& input': { cursor: 'pointer' }, + }, + disabled: { + cursor: 'default', + '& input': { cursor: 'default' }, + }, + icon: { + cursor: 'pointer', + }, + inline: { + zIndex: 1, + }, +}); - // TODO add additional classes for different states and/or slots +const usePopupSurfaceStyles = makeResetStyles({ + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + boxShadow: tokens.shadow16, + borderRadius: tokens.borderRadiusMedium, + borderWidth: tokens.strokeWidthThin, + borderStyle: 'solid', + borderColor: tokens.colorTransparentStroke, + display: 'inline-flex', + ...typographyStyles.body1, }); /** @@ -27,11 +53,30 @@ export const useDatePickerStyles_unstable = (state: DatePickerState): DatePicker 'use no memo'; const styles = useStyles(); + const popupSurfaceClassName = usePopupSurfaceStyles(); // eslint-disable-next-line react-hooks/immutability - state.root.className = mergeClasses(datePickerClassNames.root, styles.root, state.root.className); - - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.root.className = mergeClasses( + datePickerClassNames.root, + styles.root, + !state.allowTextInput && styles.selectionOnly, + state.disabled && styles.disabled, + state.root.className, + ); + const contentAfter = slot.resolveShorthand(state.root.contentAfter); + if (contentAfter) { + contentAfter.className = mergeClasses(styles.icon, state.disabled && styles.disabled, contentAfter.className); + } + // eslint-disable-next-line react-hooks/immutability + state.calendar.className = mergeClasses(datePickerClassNames.calendar, state.calendar.className); + if (state.popupSurface) { + // eslint-disable-next-line react-hooks/immutability + state.popupSurface.className = mergeClasses( + datePickerClassNames.popupSurface, + popupSurfaceClassName, + state.inlinePopup && styles.inline, + state.popupSurface.className, + ); + } return state; }; diff --git a/packages/react-components/react-datepicker-preview/library/src/index.ts b/packages/react-components/react-datepicker-preview/library/src/index.ts index ae4771023b1f0..a0e348a869db8 100644 --- a/packages/react-components/react-datepicker-preview/library/src/index.ts +++ b/packages/react-components/react-datepicker-preview/library/src/index.ts @@ -1,8 +1,20 @@ export { datePickerClassNames, + defaultDatePickerErrorStrings, DatePicker, renderDatePicker_unstable, useDatePicker_unstable, + useDatePickerBase_unstable, useDatePickerStyles_unstable, } from './DatePicker'; -export type { DatePickerProps, DatePickerState } from './DatePicker'; +export type { + DatePickerBaseProps, + DatePickerBaseState, + DatePickerDateChangeData, + DatePickerErrorType, + DatePickerOpenChangeData, + DatePickerProps, + DatePickerSlots, + DatePickerState, + DatePickerValidationResultData, +} from './DatePicker'; diff --git a/packages/react-components/react-datepicker-preview/library/tsconfig.cy.json b/packages/react-components/react-datepicker-preview/library/tsconfig.cy.json new file mode 100644 index 0000000000000..3cfb39a1086ac --- /dev/null +++ b/packages/react-components/react-datepicker-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-datepicker-preview/library/tsconfig.json b/packages/react-components/react-datepicker-preview/library/tsconfig.json index 32bdbdf1ac26f..7a15fb89d7f23 100644 --- a/packages/react-components/react-datepicker-preview/library/tsconfig.json +++ b/packages/react-components/react-datepicker-preview/library/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.cy.json" } ] } diff --git a/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json b/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json index 53066fdd11fff..9832fd3ff7df4 100644 --- a/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json +++ b/packages/react-components/react-datepicker-preview/library/tsconfig.lib.json @@ -16,7 +16,9 @@ "**/*.test.ts", "**/*.test.tsx", "**/*.stories.ts", - "**/*.stories.tsx" + "**/*.stories.tsx", + "**/*.cy.ts", + "**/*.cy.tsx" ], "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerAppearance.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerAppearance.stories.tsx new file mode 100644 index 0000000000000..ee616d0666d8f --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerAppearance.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Field, makeStyles, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; +import type { DatePickerProps } from '@fluentui/react-datepicker-preview'; + +const useStyles = makeStyles({ + stack: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, alignItems: 'flex-start' }, +}); + +export const Appearance = (): JSXElement => { + const styles = useStyles(); + const appearances: DatePickerProps['appearance'][] = ['outline', 'underline', 'filled-lighter', 'filled-darker']; + return ( +
+ {appearances.map(appearance => ( + + + + ))} +
+ ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md index 08ff8ddeeb5f8..f6b395236c269 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerBestPractices.md @@ -2,4 +2,15 @@ ### Do +- Use Field to provide a visible label, required state, hints, and localized validation messages. +- Pair custom `formatDate` and `parseDate` functions when allowing text input. +- Configure Calendar-only options through the `calendar` slot. +- Use `null` for an empty controlled date and update controlled state from event/data callbacks. +- Localize the dialog and icon names, Calendar formatters, and action content. + ### Don't + +- Do not use placeholder text as the only accessible label. +- Do not supply independent text state through the nested Input slot. +- Do not treat `today` or `calendar.defaultDisplayedDate` as a selected value. +- Do not add a second validation message or live region inside DatePicker. diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCalendarComposition.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCalendarComposition.stories.tsx new file mode 100644 index 0000000000000..9b32df5a55879 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCalendarComposition.stories.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Field, makeStyles, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +const today = new Date(2030, 0, 15); +const useStyles = makeStyles({ + stack: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, alignItems: 'flex-start' }, +}); + +export const CalendarComposition = (): JSXElement => { + const styles = useStyles(); + return ( +
+ + + + + + + + + +
+ ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerControlled.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerControlled.stories.tsx new file mode 100644 index 0000000000000..af91c76091172 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerControlled.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Button, Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +const today = new Date(2030, 0, 15); + +export const Controlled = (): JSXElement => { + const [value, setValue] = React.useState(today); + const [open, setOpen] = React.useState(false); + return ( + + setValue(data.value)} + onOpenChange={(_, data) => setOpen(data.open)} + /> + + + ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx new file mode 100644 index 0000000000000..c978a3c2370a6 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; +import { formatDate, parseDate } from './dateFormatting'; + +export const CustomDateFormatting = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateBoundaries.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateBoundaries.stories.tsx new file mode 100644 index 0000000000000..e5aa4fcb9a37f --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateBoundaries.stories.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const DateBoundaries = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateRange.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateRange.stories.tsx new file mode 100644 index 0000000000000..ecd6e9c185959 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateRange.stories.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const DateRange = (): JSXElement => { + const [range, setRange] = React.useState([]); + return ( + date.toLocaleDateString('en-US')).join(', ')}> + setRange(data.selectedDateRange)} + /> + + ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx index 3e3c9b0214ebb..12d02dbb51d66 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefault.stories.tsx @@ -1,5 +1,10 @@ import * as React from 'react'; import { DatePicker } from '@fluentui/react-datepicker-preview'; +import { Field } from '@fluentui/react-components'; import type { DatePickerProps } from '@fluentui/react-datepicker-preview'; -export const Default = (props: Partial): React.ReactElement => ; +export const Default = (props: Partial): React.ReactElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpen.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpen.stories.tsx new file mode 100644 index 0000000000000..3f3d44fac7bc9 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpen.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const DefaultOpen = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpenInline.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpenInline.stories.tsx new file mode 100644 index 0000000000000..aa50875c95c84 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDefaultOpenInline.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const DefaultOpenInline = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md index e69de29bb2d1d..ec9ea6e6319c8 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDescription.md @@ -0,0 +1,6 @@ +DatePicker combines a text input with Calendar Preview in a dismissible dialog. The input starts +empty and read-only; set `allowTextInput` to support typing. It supports controlled and uncontrolled +selection and popup visibility, Field validation, date restrictions, and Calendar composition. + +This component is in preview. Its API may change, and exact visual parity with DatePicker Compat +has not been certified. diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDisabled.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDisabled.stories.tsx new file mode 100644 index 0000000000000..f957e2e77d21a --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDisabled.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const Disabled = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerErrorHandling.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerErrorHandling.stories.tsx new file mode 100644 index 0000000000000..3934e74c72db4 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerErrorHandling.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker, defaultDatePickerErrorStrings } from '@fluentui/react-datepicker-preview'; +import type { DatePickerErrorType } from '@fluentui/react-datepicker-preview'; + +export const ErrorHandling = (): JSXElement => { + const [error, setError] = React.useState(); + return ( + + setError(data.error)} + /> + + ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerFirstDayOfTheWeek.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerFirstDayOfTheWeek.stories.tsx new file mode 100644 index 0000000000000..04ef9cf6a43e0 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerFirstDayOfTheWeek.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const FirstDayOfTheWeek = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx new file mode 100644 index 0000000000000..e163ff272c2c9 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { createCalendarDateTimeFormatter } from '@fluentui/react-calendar-preview'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; +import { formatDate, parseDate } from './dateFormatting'; + +export const Localized = (): JSXElement => ( + + `Mois précédent ${data.formattedDate}`, + nextMonthLabel: data => `Mois suivant ${data.formattedDate}`, + previousYearLabel: data => `Année précédente ${data.formattedDate}`, + nextYearLabel: data => `Année suivante ${data.formattedDate}`, + previousYearRangeLabel: data => `Années précédentes ${data.formattedRange}`, + nextYearRangeLabel: data => `Années suivantes ${data.formattedRange}`, + monthPickerHeaderLabel: data => `${data.formattedDate}, changer l'année`, + yearPickerHeaderLabel: data => `${data.formattedDate}, changer le mois`, + yearRangePickerHeaderLabel: data => `${data.formattedRange}, changer l'année`, + weekNumberLabel: data => `Semaine ${data.weekNumber}`, + selectedDateLabel: data => `Date sélectionnée ${data.formattedDate}`, + todayDateLabel: data => `Aujourd'hui ${data.formattedDate}`, + dayMarkedLabel: data => `${data.formattedDate}, marqué`, + }, + goToTodayButton: { children: "Aller à aujourd'hui" }, + }} + /> + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerPopupOptions.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerPopupOptions.stories.tsx new file mode 100644 index 0000000000000..92bd7b48fc37b --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerPopupOptions.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const PopupOptions = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerRequired.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerRequired.stories.tsx new file mode 100644 index 0000000000000..db72e497e03b3 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerRequired.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const Required = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerSizes.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerSizes.stories.tsx new file mode 100644 index 0000000000000..c327a40c9f846 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerSizes.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { Field, makeStyles, tokens } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +const useStyles = makeStyles({ + stack: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, alignItems: 'flex-start' }, +}); + +export const Sizes = (): JSXElement => { + const styles = useStyles(); + return ( +
+ {(['small', 'medium', 'large'] as const).map(size => ( + + + + ))} +
+ ); +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerTextInput.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerTextInput.stories.tsx new file mode 100644 index 0000000000000..ea5fdaaa8d824 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerTextInput.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const TextInput = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerWeekNumbers.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerWeekNumbers.stories.tsx new file mode 100644 index 0000000000000..5ffb01e191361 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerWeekNumbers.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Field } from '@fluentui/react-components'; +import type { JSXElement } from '@fluentui/react-components'; +import { DatePicker } from '@fluentui/react-datepicker-preview'; + +export const WeekNumbers = (): JSXElement => ( + + + +); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts new file mode 100644 index 0000000000000..a7df36ed21f65 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts @@ -0,0 +1,13 @@ +export const formatDate = (date: Date): string => `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`; + +export const parseDate = (text: string): Date | null => { + const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text); + if (!match) { + return null; + } + const [, day, month, year] = match.map(Number); + const date = new Date(0); + date.setFullYear(year, month - 1, day); + date.setHours(0, 0, 0, 0); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null; +}; diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx index a66fea00edb80..bac2252ed8e36 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx @@ -4,6 +4,23 @@ import descriptionMd from './DatePickerDescription.md'; import bestPracticesMd from './DatePickerBestPractices.md'; export { Default } from './DatePickerDefault.stories'; +export { DefaultOpen } from './DatePickerDefaultOpen.stories'; +export { DefaultOpenInline } from './DatePickerDefaultOpenInline.stories'; +export { Appearance } from './DatePickerAppearance.stories'; +export { CalendarComposition } from './DatePickerCalendarComposition.stories'; +export { Controlled } from './DatePickerControlled.stories'; +export { CustomDateFormatting } from './DatePickerCustomDateFormatting.stories'; +export { DateBoundaries } from './DatePickerDateBoundaries.stories'; +export { DateRange } from './DatePickerDateRange.stories'; +export { Disabled } from './DatePickerDisabled.stories'; +export { ErrorHandling } from './DatePickerErrorHandling.stories'; +export { FirstDayOfTheWeek } from './DatePickerFirstDayOfTheWeek.stories'; +export { Localized } from './DatePickerLocalized.stories'; +export { PopupOptions } from './DatePickerPopupOptions.stories'; +export { Required } from './DatePickerRequired.stories'; +export { Sizes } from './DatePickerSizes.stories'; +export { TextInput } from './DatePickerTextInput.stories'; +export { WeekNumbers } from './DatePickerWeekNumbers.stories'; export default { title: 'Preview Components/DatePicker', diff --git a/yarn.lock b/yarn.lock index a817831847652..8b325e79ba775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3608,7 +3608,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-calendar-preview@npm:*, @fluentui/react-calendar-preview@workspace:packages/react-components/react-calendar-preview/library": +"@fluentui/react-calendar-preview@npm:*, @fluentui/react-calendar-preview@workspace:^, @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: @@ -4154,14 +4154,23 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library": +"@fluentui/react-datepicker-preview@workspace:^, @fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library": version: 0.0.0-use.local resolution: "@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library" dependencies: + "@fluentui/react-calendar-preview": "workspace:^" + "@fluentui/react-field": "workspace:^" + "@fluentui/react-icons": "npm:^2.0.339" + "@fluentui/react-input": "workspace:^" "@fluentui/react-jsx-runtime": "npm:^9.4.6" + "@fluentui/react-portal": "workspace:^" + "@fluentui/react-positioning": "workspace:^" + "@fluentui/react-provider": "workspace:^" "@fluentui/react-shared-contexts": "npm:^9.26.4" + "@fluentui/react-tabster": "workspace:^" "@fluentui/react-theme": "npm:^9.2.2" "@fluentui/react-utilities": "npm:^9.26.7" + "@fluentui/scripts-cypress": "workspace:^" "@griffel/react": "npm:^1.5.32" "@swc/helpers": "npm:^0.5.1" peerDependencies: @@ -4363,7 +4372,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-field@npm:*, @fluentui/react-field@npm:^9.5.5, @fluentui/react-field@workspace:packages/react-components/react-field/library": +"@fluentui/react-field@npm:*, @fluentui/react-field@npm:^9.5.5, @fluentui/react-field@workspace:^, @fluentui/react-field@workspace:packages/react-components/react-field/library": version: 0.0.0-use.local resolution: "@fluentui/react-field@workspace:packages/react-components/react-field/library" dependencies: @@ -4709,7 +4718,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-input@npm:*, @fluentui/react-input@npm:^9.8.7, @fluentui/react-input@workspace:packages/react-components/react-input/library": +"@fluentui/react-input@npm:*, @fluentui/react-input@npm:^9.8.7, @fluentui/react-input@workspace:^, @fluentui/react-input@workspace:packages/react-components/react-input/library": version: 0.0.0-use.local resolution: "@fluentui/react-input@workspace:packages/react-components/react-input/library" dependencies: @@ -5290,7 +5299,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-portal@npm:*, @fluentui/react-portal@npm:^9.8.16, @fluentui/react-portal@workspace:packages/react-components/react-portal/library": +"@fluentui/react-portal@npm:*, @fluentui/react-portal@npm:^9.8.16, @fluentui/react-portal@workspace:^, @fluentui/react-portal@workspace:packages/react-components/react-portal/library": version: 0.0.0-use.local resolution: "@fluentui/react-portal@workspace:packages/react-components/react-portal/library" dependencies: @@ -5315,7 +5324,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-positioning@npm:*, @fluentui/react-positioning@npm:^9.23.3, @fluentui/react-positioning@workspace:packages/react-components/react-positioning/library": +"@fluentui/react-positioning@npm:*, @fluentui/react-positioning@npm:^9.23.3, @fluentui/react-positioning@workspace:^, @fluentui/react-positioning@workspace:packages/react-components/react-positioning/library": version: 0.0.0-use.local resolution: "@fluentui/react-positioning@workspace:packages/react-components/react-positioning/library" dependencies: @@ -5380,7 +5389,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-provider@npm:*, @fluentui/react-provider@npm:^9.22.21, @fluentui/react-provider@workspace:packages/react-components/react-provider/library": +"@fluentui/react-provider@npm:*, @fluentui/react-provider@npm:^9.22.21, @fluentui/react-provider@workspace:^, @fluentui/react-provider@workspace:packages/react-components/react-provider/library": version: 0.0.0-use.local resolution: "@fluentui/react-provider@workspace:packages/react-components/react-provider/library" dependencies: @@ -5832,7 +5841,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-tabster@npm:^9.26.18, @fluentui/react-tabster@workspace:packages/react-components/react-tabster": +"@fluentui/react-tabster@npm:^9.26.18, @fluentui/react-tabster@workspace:^, @fluentui/react-tabster@workspace:packages/react-components/react-tabster": version: 0.0.0-use.local resolution: "@fluentui/react-tabster@workspace:packages/react-components/react-tabster" dependencies: @@ -6351,7 +6360,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/scripts-cypress@npm:*, @fluentui/scripts-cypress@workspace:scripts/cypress": +"@fluentui/scripts-cypress@npm:*, @fluentui/scripts-cypress@workspace:^, @fluentui/scripts-cypress@workspace:scripts/cypress": version: 0.0.0-use.local resolution: "@fluentui/scripts-cypress@workspace:scripts/cypress" languageName: unknown @@ -6797,6 +6806,7 @@ __metadata: "@fluentui/react-combobox": "npm:*" "@fluentui/react-context-selector": "npm:*" "@fluentui/react-datepicker-compat": "npm:*" + "@fluentui/react-datepicker-preview": "workspace:^" "@fluentui/react-dialog": "npm:*" "@fluentui/react-divider": "npm:*" "@fluentui/react-drawer": "npm:*" From 292e3d21ad6cdb0c58cf687bf8ab6635d67fc1c8 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 07:33:07 +0200 Subject: [PATCH 06/11] fix(react-datepicker-preview): sync with calendar master and unblock CI --- apps/vr-tests-react-components/package.json | 2 +- .../library/package.json | 4 +- .../src/components/DatePicker/DatePicker.tsx | 4 +- .../DatePicker/useDatePickerStyles.styles.ts | 57 +++++++++++-------- yarn.lock | 8 +-- 5 files changed, 40 insertions(+), 35 deletions(-) diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 7a0fdc69697a0..3f82d740a32b3 100644 --- a/apps/vr-tests-react-components/package.json +++ b/apps/vr-tests-react-components/package.json @@ -28,7 +28,7 @@ "@fluentui/react-combobox": "*", "@fluentui/react-context-selector": "*", "@fluentui/react-datepicker-compat": "*", - "@fluentui/react-datepicker-preview": "workspace:^", + "@fluentui/react-datepicker-preview": "*", "@fluentui/react-dialog": "*", "@fluentui/react-divider": "*", "@fluentui/react-drawer": "*", diff --git a/packages/react-components/react-datepicker-preview/library/package.json b/packages/react-components/react-datepicker-preview/library/package.json index 8d7c3e3fe3e4d..baa3cd82dd4fe 100644 --- a/packages/react-components/react-datepicker-preview/library/package.json +++ b/packages/react-components/react-datepicker-preview/library/package.json @@ -64,7 +64,7 @@ ] }, "devDependencies": { - "@fluentui/react-provider": "workspace:^", - "@fluentui/scripts-cypress": "workspace:^" + "@fluentui/react-provider": "*", + "@fluentui/scripts-cypress": "*" } } diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx index 053feb49c373d..3ff520fc5d304 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.tsx @@ -10,9 +10,7 @@ import type { DatePickerProps } from './DatePicker.types'; * A date input with an accessible Calendar Preview popup. */ export const DatePicker: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useDatePicker_unstable(props, ref); - - useDatePickerStyles_unstable(state); + const state = useDatePickerStyles_unstable(useDatePicker_unstable(props, ref)); return renderDatePicker_unstable(state); }); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts index 491ae33a89f9d..377c2c810d794 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerStyles.styles.ts @@ -50,33 +50,40 @@ const usePopupSurfaceStyles = makeResetStyles({ * Apply styling to the DatePicker slots based on the state */ export const useDatePickerStyles_unstable = (state: DatePickerState): DatePickerState => { - 'use no memo'; - const styles = useStyles(); const popupSurfaceClassName = usePopupSurfaceStyles(); - // eslint-disable-next-line react-hooks/immutability - state.root.className = mergeClasses( - datePickerClassNames.root, - styles.root, - !state.allowTextInput && styles.selectionOnly, - state.disabled && styles.disabled, - state.root.className, - ); const contentAfter = slot.resolveShorthand(state.root.contentAfter); - if (contentAfter) { - contentAfter.className = mergeClasses(styles.icon, state.disabled && styles.disabled, contentAfter.className); - } - // eslint-disable-next-line react-hooks/immutability - state.calendar.className = mergeClasses(datePickerClassNames.calendar, state.calendar.className); - if (state.popupSurface) { - // eslint-disable-next-line react-hooks/immutability - state.popupSurface.className = mergeClasses( - datePickerClassNames.popupSurface, - popupSurfaceClassName, - state.inlinePopup && styles.inline, - state.popupSurface.className, - ); - } - return state; + return { + ...state, + root: { + ...state.root, + className: mergeClasses( + datePickerClassNames.root, + styles.root, + !state.allowTextInput && styles.selectionOnly, + state.disabled && styles.disabled, + state.root.className, + ), + contentAfter: contentAfter + ? { + ...contentAfter, + className: mergeClasses(styles.icon, state.disabled && styles.disabled, contentAfter.className), + } + : state.root.contentAfter, + }, + calendar: { + ...state.calendar, + className: mergeClasses(datePickerClassNames.calendar, state.calendar.className), + }, + popupSurface: state.popupSurface && { + ...state.popupSurface, + className: mergeClasses( + datePickerClassNames.popupSurface, + popupSurfaceClassName, + state.inlinePopup && styles.inline, + state.popupSurface.className, + ), + }, + }; }; diff --git a/yarn.lock b/yarn.lock index bb4215428fe75..85d884fd8798f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4165,12 +4165,12 @@ __metadata: "@fluentui/react-jsx-runtime": "npm:^9.4.6" "@fluentui/react-portal": "workspace:^" "@fluentui/react-positioning": "workspace:^" - "@fluentui/react-provider": "workspace:^" + "@fluentui/react-provider": "npm:*" "@fluentui/react-shared-contexts": "npm:^9.26.4" "@fluentui/react-tabster": "workspace:^" "@fluentui/react-theme": "npm:^9.2.2" "@fluentui/react-utilities": "npm:^9.26.7" - "@fluentui/scripts-cypress": "workspace:^" + "@fluentui/scripts-cypress": "npm:*" "@griffel/react": "npm:^1.5.32" "@swc/helpers": "npm:^0.5.1" peerDependencies: @@ -5390,7 +5390,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-provider@npm:*, @fluentui/react-provider@npm:^9.22.21, @fluentui/react-provider@workspace:^, @fluentui/react-provider@workspace:packages/react-components/react-provider/library": +"@fluentui/react-provider@npm:*, @fluentui/react-provider@npm:^9.22.21, @fluentui/react-provider@workspace:packages/react-components/react-provider/library": version: 0.0.0-use.local resolution: "@fluentui/react-provider@workspace:packages/react-components/react-provider/library" dependencies: @@ -6361,7 +6361,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/scripts-cypress@npm:*, @fluentui/scripts-cypress@workspace:^, @fluentui/scripts-cypress@workspace:scripts/cypress": +"@fluentui/scripts-cypress@npm:*, @fluentui/scripts-cypress@workspace:scripts/cypress": version: 0.0.0-use.local resolution: "@fluentui/scripts-cypress@workspace:scripts/cypress" languageName: unknown From 41afc9301ac7459040026ec0d4220f1e0d2587a7 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 15:37:06 +0200 Subject: [PATCH 07/11] fix(react-datepicker-preview): address review feedback --- .../components/DatePicker/DatePicker.test.tsx | 112 ++++++++++++++++++ .../components/DatePicker/useDatePicker.tsx | 10 +- .../DatePicker/useDatePickerDateState.ts | 11 +- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx index 76de2e916db4d..bfed7f058ce7e 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx @@ -102,6 +102,21 @@ describe('DatePicker', () => { expect(input.getAttribute('aria-describedby')).toBeTruthy(); }); + it('associates the Field label with the input so clicking it activates the picker', () => { + const result = render( + + + , + ); + const input = result.getByRole('combobox', { name: 'Start date' }); + const label = result.getByText('Start date'); + + expect(label.getAttribute('for')).toBe(input.id); + fireEvent.click(label); + + expect(result.getByRole('dialog')).toBeTruthy(); + }); + it('does not open on focus by default', () => { const result = render(); fireEvent.focus(result.getByRole('combobox')); @@ -131,6 +146,16 @@ describe('DatePicker', () => { expect(result.getByRole('dialog')).toBeTruthy(); }); + it('dismisses an open editable popup by clicking the input when openOnClick is false', () => { + const result = render(); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.getByRole('dialog')).toBeTruthy(); + + fireEvent.click(result.getByRole('combobox')); + + expect(result.queryByRole('dialog')).toBeNull(); + }); + it('leaves an open selection-only popup open on repeated activation', () => { const onOpenChange = jest.fn(); const result = render(); @@ -220,6 +245,48 @@ describe('DatePicker', () => { expect(result.getByRole('combobox').ownerDocument.activeElement).toBe(result.getByRole('combobox')); }); + it('reopens on the committed selection instead of the initial default displayed date', () => { + const june = new Date(2030, 5, 1); + const result = render( + , + ); + const input = result.getByRole('combobox'); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.getByRole('dialog').textContent).toContain('June 2030'); + fireEvent.click(result.getByTitle('Next month July')); + fireEvent.click(result.getByRole('button', { name: 'July 20, 2030' })); + + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + + expect(result.getByRole('dialog').textContent).toContain('July 2030'); + expect((input as HTMLInputElement).value).toBe(new Date(2030, 6, 20).toDateString()); + }); + + it('preserves a controlled displayed date when reopening after selection', () => { + const controlledDisplayedDate = new Date(2030, 5, 1); + const result = render( + , + ); + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + expect(result.getByRole('dialog').textContent).toContain('June 2030'); + fireEvent.click(result.getByRole('combobox')); + + fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); + + expect(result.getByRole('dialog').textContent).toContain('June 2030'); + }); + it('allows Calendar selection cancellation before committing or closing', () => { const onDateChange = jest.fn(); const result = render( @@ -287,6 +354,26 @@ describe('DatePicker', () => { expect((input as HTMLInputElement).value).toBe(selectedDate.toDateString()); }); + it('preserves editable draft text when an equivalent formatter callback is recreated', () => { + const ControlledDatePicker = () => { + const [, setObservedText] = React.useState(''); + return ( + date.toLocaleDateString()} + onChange={(_, data) => setObservedText(data.value)} + /> + ); + }; + const result = render(); + const input = result.getByRole('combobox'); + + fireEvent.change(input, { target: { value: 'unfinished draft' } }); + + expect((input as HTMLInputElement).value).toBe('unfinished draft'); + }); + it('closes for editing without committing the previous draft', () => { const parseDate = jest.fn(); const onDateChange = jest.fn(); @@ -328,6 +415,31 @@ describe('DatePicker', () => { expect(onValidationResult).toHaveBeenCalledTimes(1); }); + it('validates an editable date on blur after ArrowDown opens Calendar', () => { + const typedDate = new Date(2035, 4, 20); + const onDateChange = jest.fn(); + const result = render( + typedDate} + onDateChange={onDateChange} + calendar={{ layout: 'sideBySide' }} + />, + ); + const input = result.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'May 20, 2035' } }); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.blur(input); + + expect(onDateChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ value: typedDate, reason: 'input' }), + ); + expect(result.getByRole('dialog').textContent).toContain('May 2035'); + }); + it('validates once for outside pointer dismissal followed by input blur', () => { const onValidationResult = jest.fn(); const result = render( diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx index bcc9b7674baf4..ff5c5a197fc39 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx @@ -65,7 +65,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref(undefined); const outsidePointerTarget = React.useRef(null); - const inputId = useId('datePicker-input', props.id ?? props.input?.id); + const inputId = useId('datePicker-input', props.id ?? props.input?.id ?? fieldContext?.generatedControlId); const popupId = useId('datePicker-popup', slot.resolveShorthand(popupSurfaceProp)?.id); const iconId = useId('datePicker-icon'); const { targetRef, containerRef } = usePositioning({ @@ -165,7 +165,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref { consumerClick?.(event); - if (openOnClick) { + if (open || openOnClick) { activate(event, 'input'); } }); @@ -182,7 +182,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref { consumerBlur?.(event); - if (!event.defaultPrevented && !open && !skipBlurValidation.current) { + if (!event.defaultPrevented && !skipBlurValidation.current) { dateState.validate(event); } skipBlurValidation.current = false; @@ -196,6 +196,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref (date && isValidDate(date) ? formatDate(date) : ''); const timestamp = value?.getTime(); + const formattedValue = formatValue(value); const [draft, setDraft] = React.useState(() => ({ - text: formatValue(value), + text: formattedValue, dirty: false, timestamp, - formatter: formatDate, + formattedValue, })); - if (!Object.is(draft.timestamp, timestamp) || draft.formatter !== formatDate) { - setDraft({ text: formatValue(value), dirty: false, timestamp, formatter: formatDate }); + if (!Object.is(draft.timestamp, timestamp) || draft.formattedValue !== formattedValue) { + setDraft({ text: formattedValue, dirty: false, timestamp, formattedValue }); } - const updateText = (text: string, dirty: boolean) => setDraft({ text, dirty, timestamp, formatter: formatDate }); + const updateText = (text: string, dirty: boolean) => setDraft({ text, dirty, timestamp, formattedValue }); const reportValidation = (event: DatePickerEvent, error?: DatePickerErrorType) => { onValidationResult?.(event, { type: event.type, event, error }); From 22f857450add1fd51f4553a8125b1cd19fcc3349 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 15:38:57 +0200 Subject: [PATCH 08/11] perf(react-datepicker-preview): memoize formatted value --- .../src/components/DatePicker/useDatePickerDateState.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts index dc3b677908b72..019fc155fe05d 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts @@ -72,7 +72,10 @@ export const useDatePickerDateState = (options: DatePickerDateStateOptions): Dat }); const formatValue = (date: Date | null): string => (date && isValidDate(date) ? formatDate(date) : ''); const timestamp = value?.getTime(); - const formattedValue = formatValue(value); + const formattedValue = React.useMemo( + () => (value && isValidDate(value) ? formatDate(value) : ''), + [formatDate, value], + ); const [draft, setDraft] = React.useState(() => ({ text: formattedValue, dirty: false, From db1160977e3af8617baea41174fcb92d12c34a66 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 15:46:51 +0200 Subject: [PATCH 09/11] chore(react-datepicker-preview): simplify story formatting --- apps/vr-tests-react-components/package.json | 1 - .../DatePickerCustomDateFormatting.stories.tsx | 12 +++++++++--- .../src/DatePicker/DatePickerLocalized.stories.tsx | 12 +++++++++--- .../stories/src/DatePicker/dateFormatting.ts | 13 ------------- yarn.lock | 3 +-- 5 files changed, 19 insertions(+), 22 deletions(-) delete mode 100644 packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 660f2b9deadc1..1b9ed4fea83db 100644 --- a/apps/vr-tests-react-components/package.json +++ b/apps/vr-tests-react-components/package.json @@ -28,7 +28,6 @@ "@fluentui/react-combobox": "*", "@fluentui/react-context-selector": "*", "@fluentui/react-datepicker-compat": "*", - "@fluentui/react-datepicker-preview": "*", "@fluentui/react-dialog": "*", "@fluentui/react-divider": "*", "@fluentui/react-drawer": "*", diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx index c978a3c2370a6..9fa1f189d5d6b 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Field } from '@fluentui/react-components'; import type { JSXElement } from '@fluentui/react-components'; import { DatePicker } from '@fluentui/react-datepicker-preview'; -import { formatDate, parseDate } from './dateFormatting'; export const CustomDateFormatting = (): JSXElement => ( @@ -10,8 +9,15 @@ export const CustomDateFormatting = (): JSXElement => ( allowTextInput today={new Date(2030, 0, 15)} defaultValue={new Date(2030, 0, 15)} - formatDate={formatDate} - parseDate={parseDate} + formatDate={date => new Intl.DateTimeFormat('en-GB').format(date)} + parseDate={text => { + const [day, month, year] = text.split('/').map(Number); + if (!day || !month || !year) { + return null; + } + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null; + }} /> ); diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx index e163ff272c2c9..09952a16eec50 100644 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx @@ -3,7 +3,6 @@ import { Field } from '@fluentui/react-components'; import type { JSXElement } from '@fluentui/react-components'; import { createCalendarDateTimeFormatter } from '@fluentui/react-calendar-preview'; import { DatePicker } from '@fluentui/react-datepicker-preview'; -import { formatDate, parseDate } from './dateFormatting'; export const Localized = (): JSXElement => ( @@ -11,8 +10,15 @@ export const Localized = (): JSXElement => ( allowTextInput today={new Date(2030, 0, 15)} defaultValue={new Date(2030, 0, 15)} - formatDate={formatDate} - parseDate={parseDate} + formatDate={date => new Intl.DateTimeFormat('fr-FR').format(date)} + parseDate={text => { + const [day, month, year] = text.split('/').map(Number); + if (!day || !month || !year) { + return null; + } + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null; + }} contentAfter={{ 'aria-label': 'Ouvrir le calendrier' }} popupSurface={{ 'aria-label': 'Calendrier' }} calendar={{ diff --git a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts deleted file mode 100644 index a7df36ed21f65..0000000000000 --- a/packages/react-components/react-datepicker-preview/stories/src/DatePicker/dateFormatting.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const formatDate = (date: Date): string => `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`; - -export const parseDate = (text: string): Date | null => { - const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text); - if (!match) { - return null; - } - const [, day, month, year] = match.map(Number); - const date = new Date(0); - date.setFullYear(year, month - 1, day); - date.setHours(0, 0, 0, 0); - return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null; -}; diff --git a/yarn.lock b/yarn.lock index 63af80449f55a..8e075f51c068d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4182,7 +4182,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-datepicker-preview@npm:*, @fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library": +"@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library": version: 0.0.0-use.local resolution: "@fluentui/react-datepicker-preview@workspace:packages/react-components/react-datepicker-preview/library" dependencies: @@ -6863,7 +6863,6 @@ __metadata: "@fluentui/react-combobox": "npm:*" "@fluentui/react-context-selector": "npm:*" "@fluentui/react-datepicker-compat": "npm:*" - "@fluentui/react-datepicker-preview": "npm:*" "@fluentui/react-dialog": "npm:*" "@fluentui/react-divider": "npm:*" "@fluentui/react-drawer": "npm:*" From 6e6e6fa248ab1290aaa371998d6ab6dd7af6e7eb Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 16:35:16 +0200 Subject: [PATCH 10/11] fix(react-datepicker-preview): handle reviewed edge cases --- .../components/DatePicker/DatePicker.test.tsx | 77 ++++++++++++++++++- .../components/DatePicker/useDatePicker.tsx | 10 ++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx index bfed7f058ce7e..884f5af95def0 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.test.tsx @@ -248,7 +248,12 @@ describe('DatePicker', () => { it('reopens on the committed selection instead of the initial default displayed date', () => { const june = new Date(2030, 5, 1); const result = render( - , + , ); const input = result.getByRole('combobox'); fireEvent.click(result.getByRole('button', { name: 'Open calendar' })); @@ -262,6 +267,51 @@ describe('DatePicker', () => { expect((input as HTMLInputElement).value).toBe(new Date(2030, 6, 20).toDateString()); }); + it('opens with a default displayed date when a selection already exists', () => { + const june = new Date(2030, 5, 1); + const result = render( + , + ); + + fireEvent.click(result.getByRole('combobox')); + + expect(result.getByRole('dialog').textContent).toContain('June 2030'); + }); + + it('opens using a valid default displayed date when the selection is invalid', () => { + const june = new Date(2030, 5, 1); + const result = render( + , + ); + + fireEvent.click(result.getByRole('combobox')); + + expect(result.getByRole('dialog').textContent).toContain('June 2030'); + }); + + it('does not use an invalid selection as the displayed date', () => { + const invalidDate = new Date(NaN); + const onValidationResult = jest.fn(); + const result = render(); + + fireEvent.click(result.getByRole('combobox')); + + expect(result.getByRole('dialog')).toBeTruthy(); + fireEvent.blur(result.getByRole('combobox')); + expect(onValidationResult).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ error: 'invalid-input' }), + ); + }); + it('preserves a controlled displayed date when reopening after selection', () => { const controlledDisplayedDate = new Date(2030, 5, 1); const result = render( @@ -415,6 +465,31 @@ describe('DatePicker', () => { expect(onValidationResult).toHaveBeenCalledTimes(1); }); + it('validates new input after a controlled Enter opening request is declined', () => { + const onDateChange = jest.fn(); + const onValidationResult = jest.fn(); + const result = render( + selectedDate} + onDateChange={onDateChange} + onValidationResult={onValidationResult} + />, + ); + const input = result.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'first draft' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(result.queryByRole('dialog')).toBeNull(); + expect(onValidationResult).toHaveBeenCalledTimes(1); + + fireEvent.change(input, { target: { value: 'second draft' } }); + fireEvent.blur(input); + + expect(onValidationResult).toHaveBeenCalledTimes(2); + expect(onDateChange).toHaveBeenCalledTimes(2); + }); + it('validates an editable date on blur after ArrowDown opens Calendar', () => { const typedDate = new Date(2035, 4, 20); const onDateChange = jest.fn(); diff --git a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx index ff5c5a197fc39..550c65a657964 100644 --- a/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx @@ -63,6 +63,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref(undefined); const focusWithinPopup = React.useRef(false); const skipBlurValidation = React.useRef(false); + const [hasOpened, setHasOpened] = React.useState(false); const lastCloseEvent = React.useRef(undefined); const outsidePointerTarget = React.useRef(null); const inputId = useId('datePicker-input', props.id ?? props.input?.id ?? fieldContext?.generatedControlId); @@ -158,6 +159,7 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref { if (popupOpen && !wasOpen.current) { + setHasOpened(true); dayRef.current?.focus({ preventScroll: true }); if (!surfaceRef.current?.contains(targetDocument?.activeElement ?? null)) { monthRef.current?.focus({ preventScroll: true }); From 4c27b6a85629130847ed0342d6f051e0a387156f Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 21 Oct 2026 17:39:41 +0200 Subject: [PATCH 11/11] chore(react-datepicker-preview): remove VR story --- .../DatePickerPreview.stories.tsx | 188 ------------------ 1 file changed, 188 deletions(-) delete mode 100644 apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx diff --git a/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx b/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx deleted file mode 100644 index bfde2e5d06e8d..0000000000000 --- a/apps/vr-tests-react-components/src/stories/DatePickerPreview/DatePickerPreview.stories.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import * as React from 'react'; -import type { Meta } from '@storybook/react-webpack5'; -import { Steps } from 'storywright'; -import type { StoryParameters } from 'storywright'; -import { makeStyles } from '@griffel/react'; -import { teamsLightTheme, tokens } from '@fluentui/react-theme'; -import { FluentProvider } from '@fluentui/react-provider'; -import { DatePicker as DatePickerCompat } from '@fluentui/react-datepicker-compat'; -import { DateRangeType, DayOfWeek } from '@fluentui/react-calendar-compat'; -import type { DatePickerProps as CompatProps } from '@fluentui/react-datepicker-compat'; -import { DatePicker } from '@fluentui/react-datepicker-preview'; -import type { DatePickerProps } from '@fluentui/react-datepicker-preview'; -import type { JSXElement } from '@fluentui/react-utilities'; -import { DARK_MODE, HIGH_CONTRAST, RTL, getStoryVariant, TestWrapperDecorator } from '../../utilities'; - -const today = new Date(2030, 0, 15); -const useStyles = makeStyles({ - pair: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalXXXL }, - example: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: tokens.spacingVerticalM }, -}); - -export default { - title: 'DatePicker Preview', - component: DatePicker, - decorators: [TestWrapperDecorator], - parameters: { - storyWright: { steps: new Steps().snapshot('rest', { cropTo: '.testWrapper' }).end() }, - } satisfies StoryParameters, -} satisfies Meta; - -const Pair = ({ preview = {}, compat = {} }: { preview?: DatePickerProps; compat?: CompatProps }): JSXElement => { - const styles = useStyles(); - const [compatSize, setCompatSize] = React.useState({ width: 0, height: 0 }); - const [previewSize, setPreviewSize] = React.useState({ width: 0, height: 0 }); - const measureCompat = React.useCallback((node: HTMLDivElement | null) => { - const surface = node?.querySelector('[role="dialog"]'); - if (surface) { - const { width, height } = surface.getBoundingClientRect(); - setCompatSize({ width, height }); - } - }, []); - const measurePreview = React.useCallback((node: HTMLDivElement | null) => { - const surface = node?.querySelector('[role="dialog"]'); - if (surface) { - const { width, height } = surface.getBoundingClientRect(); - setPreviewSize({ width, height }); - } - }, []); - return ( -
-
- Compat reference - -
-
- Preview - -
-
- ); -}; - -export const Default = (): JSXElement => ; -export const Selected = (): JSXElement => ; -export const Open = (): JSXElement => ; -export const Editable = (): JSXElement => ; -export const Disabled = (): JSXElement => ; -export const Invalid = (): JSXElement => ; -export const Underline = (): JSXElement => ; -export const FilledLighter = (): JSXElement => ( - -); -export const Small = (): JSXElement => ; -export const Large = (): JSXElement => ; -export const SideBySide = (): JSXElement => ( - -); -export const MonthOnly = (): JSXElement => ( - -); -export const WeekRange = (): JSXElement => ( - -); -export const WorkWeekRange = (): JSXElement => ( - -); -export const MonthRange = (): JSXElement => ( - -); -export const Restricted = (): JSXElement => ( - -); -export const DayOnly = (): JSXElement => ( - -); -export const Overlay = (): JSXElement => ( - -); -export const WeekNumbers = (): JSXElement => ( - -); -export const OpenDark = getStoryVariant(Open, DARK_MODE); -export const OpenHighContrast = getStoryVariant(Open, HIGH_CONTRAST); -export const OpenRtl = getStoryVariant(Open, RTL); -export const OpenTeamsTheme = (): JSXElement => ( - - - -); -export const SmallOpen = (): JSXElement => ( - -); -export const LargeOpen = (): JSXElement => ( - -); -export const YearView = (): JSXElement => ; -YearView.parameters = { - storyWright: { - steps: new Steps() - .click('.fui-CalendarPicker__currentItemButton') - .click('.fui-CalendarMonth__heading') - .snapshot('year pickers', { cropTo: '.testWrapper' }) - .end(), - }, -} satisfies StoryParameters; -export const Focused = (): JSXElement => ; -Focused.parameters = { - storyWright: { - steps: new Steps() - .focus('[data-testid="compat"] input') - .snapshot('compat focus', { cropTo: '.testWrapper' }) - .focus('[data-testid="preview"] input') - .snapshot('preview focus', { cropTo: '.testWrapper' }) - .end(), - }, -} satisfies StoryParameters; - -export const Popup = (): JSXElement => ; -Popup.parameters = { - storyWright: { - steps: new Steps() - .snapshot('positioned', { cropTo: 'body' }) - .keys('[role="dialog"]', 'Escape') - .snapshot('dismissed', { cropTo: 'body' }) - .end(), - }, -} satisfies StoryParameters;