diff --git a/packages/react-components/react-datepicker-preview/library/README.md b/packages/react-components/react-datepicker-preview/library/README.md index 5a19ad42e68317..791b8d602777d3 100644 --- a/packages/react-components/react-datepicker-preview/library/README.md +++ b/packages/react-components/react-datepicker-preview/library/README.md @@ -3,3 +3,25 @@ **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. + +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. + +```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 2e211ae9e21420..0d1d3e044a172a 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 00000000000000..ca52cf041bbf2c --- /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 679e4151df123b..3eb5cdcc28636a 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 e2b71f8801117e..72404bf4d6b2e6 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 fa428df69af764..baa3cd82dd4fe5 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": "*", + "@fluentui/scripts-cypress": "*" } } diff --git a/packages/react-components/react-datepicker-preview/library/project.json b/packages/react-components/react-datepicker-preview/library/project.json index 3dab67782c8577..916e2d91829c07 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 00000000000000..da5828f347f920 --- /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 bd248a994b0e0b..86c0abfa9cd64b 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 00000000000000..815545d19c831d --- /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 60bcff06a3cd90..884f5af95def08 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,737 @@ 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('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')); + 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('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(); + 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('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('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( + , + ); + 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( + 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('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(); + 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 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(); + 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( + <> + + + , + ); + 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 e283a2da05fbc5..3ff520fc5d3047 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,22 +7,10 @@ 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); + 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/DatePicker.types.test.tsx b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/DatePicker.types.test.tsx new file mode 100644 index 00000000000000..9553a135888656 --- /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 a140322c309349..9a0f69b4c23b6f 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 e0db7d1cbf9a40..5cb41f8ea34aaa 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 00000000000000..f81d8483fb6dec --- /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 11adf7404e60dd..6c770f94d33ecf 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 24b75a9f1b1ce8..226513a6aaab5a 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 9c5851e42033ec..00000000000000 --- 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 00000000000000..550c65a6579642 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePicker.tsx @@ -0,0 +1,376 @@ +'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 [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); + 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) { + skipBlurValidation.current = false; + dateState.setText(data.value); + requestOpen(event, false, 'input'); + } + }); + const consumerClick = input.onClick ?? root.onClick; + input.onClick = useEventCallback(event => { + consumerClick?.(event); + if (open || 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 && !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); + skipBlurValidation.current = true; + 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; + if ( + hasOpened && + calendar.displayedDate === undefined && + dateState.value && + Number.isFinite(dateState.value.getTime()) + ) { + calendar.defaultDisplayedDate = dateState.value; + } + 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) { + setHasOpened(true); + 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 00000000000000..625c1ed989ee23 --- /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 00000000000000..1998ce7775c471 --- /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/useDatePickerDateState.test.ts b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.test.ts new file mode 100644 index 00000000000000..c108913ea6af0a --- /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 00000000000000..019fc155fe05d0 --- /dev/null +++ b/packages/react-components/react-datepicker-preview/library/src/components/DatePicker/useDatePickerDateState.ts @@ -0,0 +1,180 @@ +'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 formattedValue = React.useMemo( + () => (value && isValidDate(value) ? formatDate(value) : ''), + [formatDate, value], + ); + const [draft, setDraft] = React.useState(() => ({ + text: formattedValue, + dirty: false, + timestamp, + formattedValue, + })); + + 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, formattedValue }); + + 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 }; +}; 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 639181655e72ba..377c2c810d7944 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, }); /** @@ -25,11 +51,39 @@ const useStyles = makeStyles({ */ export const useDatePickerStyles_unstable = (state: DatePickerState): DatePickerState => { 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); + const popupSurfaceClassName = usePopupSurfaceStyles(); + const contentAfter = slot.resolveShorthand(state.root.contentAfter); - 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/packages/react-components/react-datepicker-preview/library/src/index.ts b/packages/react-components/react-datepicker-preview/library/src/index.ts index ae4771023b1f03..a0e348a869db8f 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 00000000000000..3cfb39a1086ac0 --- /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 32bdbdf1ac26f0..7a15fb89d7f233 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 53066fdd11fff0..9832fd3ff7df46 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 00000000000000..ee616d0666d8f1 --- /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 08ff8ddeeb5f86..f6b395236c2691 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 00000000000000..9b32df5a55879c --- /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 00000000000000..af91c760911722 --- /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 00000000000000..9fa1f189d5d6ba --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerCustomDateFormatting.stories.tsx @@ -0,0 +1,23 @@ +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 CustomDateFormatting = (): JSXElement => ( + + 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/DatePickerDateBoundaries.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerDateBoundaries.stories.tsx new file mode 100644 index 00000000000000..e5aa4fcb9a37f1 --- /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 00000000000000..ecd6e9c1859599 --- /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 3e3c9b0214ebbc..12d02dbb51d660 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 00000000000000..3f3d44fac7bc96 --- /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 00000000000000..aa50875c95c840 --- /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 e69de29bb2d1d6..ec9ea6e6319c8e 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 00000000000000..f957e2e77d21ab --- /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 00000000000000..3934e74c72db44 --- /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 00000000000000..04ef9cf6a43e0c --- /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 00000000000000..09952a16eec50c --- /dev/null +++ b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/DatePickerLocalized.stories.tsx @@ -0,0 +1,46 @@ +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'; + +export const Localized = (): JSXElement => ( + + 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={{ + firstDayOfWeek: 'monday', + formatters: { + dateTime: createCalendarDateTimeFormatter('fr-FR'), + previousMonthLabel: data => `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 00000000000000..92bd7b48fc37be --- /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 00000000000000..db72e497e03b38 --- /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 00000000000000..c327a40c9f846e --- /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 00000000000000..ea5fdaaa8d8244 --- /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 00000000000000..5ffb01e1913613 --- /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/index.stories.tsx b/packages/react-components/react-datepicker-preview/stories/src/DatePicker/index.stories.tsx index a66fea00edb804..bac2252ed8e363 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 f4727feddc7664..8e075f51c068d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3636,7 +3636,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: @@ -4186,10 +4186,19 @@ __metadata: 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": "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": "npm:*" "@griffel/react": "npm:^1.5.32" "@swc/helpers": "npm:^0.5.1" peerDependencies: @@ -4391,7 +4400,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-field@npm:*, @fluentui/react-field@npm:^9.5.6, @fluentui/react-field@workspace:packages/react-components/react-field/library": +"@fluentui/react-field@npm:*, @fluentui/react-field@npm:^9.5.6, @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: @@ -4738,7 +4747,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-input@npm:*, @fluentui/react-input@npm:^9.8.8, @fluentui/react-input@workspace:packages/react-components/react-input/library": +"@fluentui/react-input@npm:*, @fluentui/react-input@npm:^9.8.8, @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: @@ -5319,7 +5328,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-portal@npm:*, @fluentui/react-portal@npm:^9.8.17, @fluentui/react-portal@workspace:packages/react-components/react-portal/library": +"@fluentui/react-portal@npm:*, @fluentui/react-portal@npm:^9.8.17, @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: @@ -5344,7 +5353,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-positioning@npm:*, @fluentui/react-positioning@npm:^9.23.4, @fluentui/react-positioning@workspace:packages/react-components/react-positioning/library": +"@fluentui/react-positioning@npm:*, @fluentui/react-positioning@npm:^9.23.4, @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: @@ -5889,7 +5898,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-tabster@npm:^9.26.19, @fluentui/react-tabster@workspace:packages/react-components/react-tabster": +"@fluentui/react-tabster@npm:^9.26.19, @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: