diff --git a/change/@fluentui-react-headless-components-preview-e74c0e8e-4c60-49b2-b401-c01b375fbc31.json b/change/@fluentui-react-headless-components-preview-e74c0e8e-4c60-49b2-b401-c01b375fbc31.json new file mode 100644 index 0000000000000..6ab6652c44182 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-e74c0e8e-4c60-49b2-b401-c01b375fbc31.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "Add an unstyled DatePicker with shared date validation, headless Calendar composition, and native modal-dialog behavior.", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 6d2171cf5db2b..f5af7b43f56d9 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -130,6 +130,7 @@ import { renderCompoundButton, useCompoundButton, } from '@fluentui/react-headless-components-preview/compound-button'; +import { DatePicker, renderDatePicker, useDatePicker } from '@fluentui/react-headless-components-preview/date-picker'; import { Dialog, DialogActions, @@ -733,6 +734,9 @@ console.log( CompoundButton, renderCompoundButton, useCompoundButton, + DatePicker, + renderDatePicker, + useDatePicker, Dialog, DialogActions, DialogBody, diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/DatePicker.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/DatePicker.fixture.js new file mode 100644 index 0000000000000..c86bac915270e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/DatePicker.fixture.js @@ -0,0 +1,7 @@ +import { DatePicker } from '@fluentui/react-headless-components-preview/date-picker'; + +console.log(DatePicker); + +export default { + name: 'DatePicker', +}; diff --git a/packages/react-components/react-headless-components-preview/library/etc/date-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/date-picker.api.md new file mode 100644 index 0000000000000..1290e96bdfdc9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/date-picker.api.md @@ -0,0 +1,80 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { CalendarBaseProps } from '@fluentui/react-calendar-preview'; +import { CalendarDayBaseProps } from '@fluentui/react-calendar-preview'; +import { CalendarDayHandle } from '@fluentui/react-calendar-preview'; +import type { CalendarMonthBaseProps } from '@fluentui/react-calendar-preview'; +import { CalendarMonthHandle } from '@fluentui/react-calendar-preview'; +import type { CalendarMonthSlots as CalendarMonthSlots_2 } from '@fluentui/react-calendar-preview'; +import type { CalendarSlots as CalendarSlots_2 } from '@fluentui/react-calendar-preview'; +import { CalendarYearBaseProps } from '@fluentui/react-calendar-preview'; +import { CalendarYearHandle } from '@fluentui/react-calendar-preview'; +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import type { DatePickerBaseProps } from '@fluentui/react-datepicker-preview'; +import type { DatePickerBaseState } from '@fluentui/react-datepicker-preview'; +import { DatePickerDateChangeData } from '@fluentui/react-datepicker-preview'; +import { DatePickerErrorType } from '@fluentui/react-datepicker-preview'; +import { DatePickerOpenChangeData } from '@fluentui/react-datepicker-preview'; +import { DatePickerValidationResultData } from '@fluentui/react-datepicker-preview'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { InputBaseProps } from '@fluentui/react-input'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningShorthandValue } from '@fluentui/react-positioning'; +import * as React_2 from 'react'; +import type { RefAttributes } from '@fluentui/react-utilities'; +import type { Slot } from '@fluentui/react-utilities'; + +// @public +export const DatePicker: ForwardRefComponent; + +export { DatePickerDateChangeData } + +export { DatePickerErrorType } + +export { DatePickerOpenChangeData } + +// @public +export type DatePickerProps = Omit & { + calendar?: DatePickerSlots['calendar']; + trigger?: DatePickerSlots['trigger']; + popupSurface?: DatePickerSlots['popupSurface']; + positioning?: PositioningShorthand; +}; + +// @public +export type DatePickerSlots = { + root: NonNullable>; + calendar: NonNullable & RefAttributes>>; + trigger: NonNullable>; + popupSurface: NonNullable>; +}; + +// @public (undocumented) +export type DatePickerState = ComponentState & Pick & { + renderSurface: boolean; + root: { + root: { + 'data-open'?: ''; + 'data-disabled'?: ''; + 'data-invalid'?: ''; + }; + }; +}; + +export { DatePickerValidationResultData } + +// @public +export const renderDatePicker: (state: DatePickerState) => JSXElement; + +// @public +export const useDatePicker: (props: DatePickerProps, ref: React_2.Ref) => DatePickerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/utils.api.md b/packages/react-components/react-headless-components-preview/library/etc/utils.api.md index 71fa91e7c2abe..8af5cc2306bda 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/utils.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/utils.api.md @@ -5,6 +5,9 @@ ```ts // @public +export function toDataAttributeValue(value: boolean | undefined): '' | undefined; + +// @public (undocumented) export function toDataAttributeValue(value: boolean | string | number | undefined): string | undefined; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index d8df5de297cff..9366c152fc656 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -26,11 +26,13 @@ "@fluentui/react-badge": "^9.5.6", "@fluentui/react-breadcrumb": "^9.4.6", "@fluentui/react-button": "^9.11.1", + "@fluentui/react-calendar-preview": "workspace:^", "@fluentui/react-card": "^9.7.3", "@fluentui/react-checkbox": "^9.6.5", "@fluentui/react-color-picker": "^9.3.1", "@fluentui/react-combobox": "^9.17.7", "@fluentui/react-context-selector": "^9.2.20", + "@fluentui/react-datepicker-preview": "workspace:^", "@fluentui/react-dialog": "^9.18.5", "@fluentui/react-divider": "^9.7.5", "@fluentui/react-drawer": "^9.13.4", @@ -202,6 +204,16 @@ "default": "./lib-commonjs/compound-button.cjs" } }, + "./date-picker": { + "import": { + "types": "./dist/date-picker.d.ts", + "default": "./lib/date-picker.js" + }, + "require": { + "types": "./dist/date-picker.d.cts", + "default": "./lib-commonjs/date-picker.cjs" + } + }, "./dialog": { "import": { "types": "./dist/dialog.d.ts", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/DatePicker/DatePicker.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/DatePicker/DatePicker.cy.tsx new file mode 100644 index 0000000000000..8ff79c242a5e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/DatePicker/DatePicker.cy.tsx @@ -0,0 +1,219 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { mount } from '@fluentui/scripts-cypress'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill'; +import { Provider } from '../Provider'; +import { DatePicker } from './DatePicker'; + +polyfillBodyAndObserve(); +const today = new Date(2030, 0, 15); +const day = (label: string) => `td[role="gridcell"]:has(button[aria-label="${label}"])`; + +describe('headless DatePicker', () => { + it('opens from the keyboard, navigates and selects, then restores input focus without reopening', () => { + const onDateChange = cy.stub().as('dateChange'); + const onOpenChange = cy.stub().as('openChange'); + mount( + + + , + ); + cy.get('input').focus(); + cy.get('dialog').should('be.visible').and('have.attr', 'open'); + cy.get(day('January 15, 2030')).should('be.focused').realPress('ArrowRight'); + cy.get(day('January 16, 2030')).should('be.focused').realPress('Enter'); + cy.get('dialog').should('not.exist'); + cy.get('input').should('be.focused').and('have.value', 'Wed Jan 16 2030'); + cy.get('@dateChange').should('have.been.calledOnce'); + cy.get('@openChange').should('have.been.calledTwice'); + cy.get('input').realPress('ArrowDown'); + cy.get(day('January 16, 2030')).should('be.focused').realPress('Escape'); + cy.get('dialog').should('not.exist'); + cy.get('input').should('be.focused'); + }); + + it('makes outside content inert while modal and dismisses with Escape', () => { + mount( + + + + , + ); + cy.get('input').focus().realPress('Enter'); + cy.get('dialog').should('be.visible'); + cy.get('dialog').should(surface => expect(surface[0].matches(':modal')).to.equal(true)); + cy.get(day('January 15, 2030')).focus(); + cy.contains('button', 'Outside').then(button => { + button[0].focus(); + expect(button[0].ownerDocument.activeElement).not.to.equal(button[0]); + }); + cy.get(day('January 15, 2030')).focus().realPress('Escape'); + cy.get('dialog').should('not.exist'); + cy.get('input').should('be.focused'); + }); + + it('dismisses a backdrop click and does not immediately reopen', () => { + mount( + + + , + ); + cy.get('input').focus(); + cy.get('dialog').should('be.visible'); + cy.get('dialog').click(-10, -10, { force: true }); + cy.get('dialog').should('not.exist'); + cy.get('input').should('be.focused'); + }); + + it('keeps rejected controlled close requests modal', () => { + const onOpenChange = cy.stub().as('openChange'); + mount( + + + , + ); + cy.get('dialog').should('be.visible'); + cy.focused().realPress('Escape'); + cy.get('dialog').should('be.visible').and('have.attr', 'open'); + cy.get('@openChange').should('have.been.calledOnce'); + }); + + it('renders, selects, and restores focus in the input owner document', () => { + const Example = () => { + const [frame, setFrame] = React.useState(null); + const targetDocument = frame?.contentDocument; + return ( + <> +