diff --git a/change/@fluentui-react-headless-components-preview-6def4baa-5ccb-46a3-92a8-da4416b5111b.json b/change/@fluentui-react-headless-components-preview-6def4baa-5ccb-46a3-92a8-da4416b5111b.json new file mode 100644 index 00000000000000..b76c6d847068ef --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-6def4baa-5ccb-46a3-92a8-da4416b5111b.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: update non-modal dialog implementation to use popover API", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md b/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md index a247dfd61f5487..e345cb858b4b8b 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md @@ -87,6 +87,10 @@ export type DialogOpenChangeData = { type: 'backdropClick'; open: boolean; event: React_2.MouseEvent; +} | { + type: 'surfaceToggle'; + open: boolean; + event: Event; } | { type: 'triggerClick'; open: boolean; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx index d7f73c7818ba28..3c847ea2922f72 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx @@ -16,7 +16,7 @@ const dialogTriggerId = 'dialog-trigger'; const dialogTriggerOpenId = `${dialogTriggerId}-open`; const dialogTriggerCloseId = `${dialogTriggerId}-close`; const dialogPrimaryButtonId = 'do-something-btn'; -const dialogSurfaceSelector = `dialog[open]`; +const dialogSurfaceSelector = `dialog[data-open]`; const dialogSurfaceElementSelector = `dialog`; const dialogTriggerOpenSelector = `#${dialogTriggerOpenId}`; const dialogTriggerCloseSelector = `#${dialogTriggerCloseId}`; @@ -310,10 +310,9 @@ describe('Dialog', () => { <> {/* The headless component ships no default styles. The UA stylesheet combined - with the portal mount node (`position: absolute; top: 0; left: 0; right: 0`) - pins the non-modal dialog to the top of the viewport, where it would overlap - and intercept clicks on the sibling "outside" button. Position it out of the - way so this test can verify focus behaviour rather than layout. + with top-layer placement can visually overlap the sibling "outside" button. + Position it out of the way so this test can verify focus behaviour rather + than layout. */} { cy.get(dialogPrimaryButtonSelector).realClick().should('be.focused').realType('{esc}'); cy.get(dialogSurfaceSelector).should('not.exist'); }); + + it('should move focus into dialog when Tab is pressed after clicking dialog surface', () => { + mount( + <> + + + , + ); + cy.get(dialogTriggerOpenSelector).realClick(); + cy.get('#extra-btn-outside').realClick().should('be.focused'); + // Click on the dialog surface itself (not on a focusable descendant). + // Focus lands on the via its tabIndex=-1; pressing Tab should + // step into the dialog's first focusable descendant. + cy.get(dialogSurfaceElementSelector).realClick(); + cy.realPress('Tab'); + cy.get(dialogTriggerCloseSelector).should('be.focused'); + }); }); it('should allow nested dialogs', () => { @@ -376,4 +397,60 @@ describe('Dialog', () => { cy.get('#close-first-dialog-btn').should('exist').realClick(); cy.get(dialogSurfaceSelector).should('not.exist'); }); + + it('should render nested non-modal dialog in modal dialog accessibly', () => { + mount( + + + + + + + Outer dialog title + + + + + + + Inner non-modal dialog title + + + + + + + , + ); + + cy.get('dialog[data-modal-type="modal"]').should('not.exist'); + cy.get('dialog[data-modal-type="non-modal"]').should('not.exist'); + + cy.get('#open-outer-dialog-btn').realClick(); + cy.get('dialog[data-modal-type="modal"]').should('have.length', 1); + cy.get('dialog[data-modal-type="non-modal"]').should('not.exist'); + + cy.get('#open-inner-dialog-btn').realClick(); + cy.get('dialog[data-modal-type="non-modal"]').should('have.length', 1); + + // Outer dialog should be modal and have an accessible name from title linkage. + cy.contains('h2', 'Outer dialog title') + .invoke('attr', 'id') + .then(outerTitleId => { + cy.get('dialog[data-modal-type="modal"]').should('have.attr', 'aria-modal', 'true'); + cy.get('dialog[data-modal-type="modal"]').should('have.attr', 'aria-labelledby', outerTitleId); + }); + + // Inner dialog should be non-modal, remain non-aria-modal, and expose title linkage. + cy.contains('h2', 'Inner non-modal dialog title') + .invoke('attr', 'id') + .then(innerTitleId => { + cy.get('dialog[data-modal-type="non-modal"]').should('not.have.attr', 'aria-modal'); + cy.get('dialog[data-modal-type="non-modal"]').should('have.attr', 'aria-labelledby', innerTitleId); + }); + + cy.get('#inner-non-modal-action').should('exist'); + cy.get('dialog[data-modal-type="modal"]').should('have.length', 1); + cy.get('dialog[data-modal-type="non-modal"]').should('have.length', 1); + }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.tsx index e6b2f68eda6805..40bef5ccc171b1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.tsx @@ -10,7 +10,7 @@ import type { DialogSurfaceProps } from './DialogSurface.types'; * `DialogSurface` renders the native HTML `` element. * * Place it as a direct child of `Dialog` (alongside an optional `DialogTrigger`). - * It manages open/close via `showModal()` / `show()` / `close()` imperatively, + * It manages open/close via `showModal()` / `showPopover()` / `close()` imperatively, * keeping the native element in sync with the controlled `open` prop. * * The native `::backdrop` pseudo-element provides the modal backdrop. diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts index d9d4f400ef6179..0bf40ed8762c42 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts @@ -4,7 +4,7 @@ import type { DialogModalType } from '../dialogContext'; export type DialogSurfaceSlots = { /** * The native HTML `` element. - * Opened as modal via `showModal()` or non-modal via `show()`. + * Opened as modal via `showModal()` or non-modal via `showPopover()`. * The `::backdrop` CSS pseudo-element provides the native backdrop for modal dialogs. */ root: Slot<'dialog'>; @@ -31,8 +31,8 @@ export type DialogSurfaceState = ComponentState & { /** * Modality of the dialog. Mirrors DialogContext's `modalType`. * - `modal` / `alert`: opened via `showModal()`; rendered into the browser top layer. - * - `non-modal`: opened via `show()`; portalled into `document.body` to escape ancestor - * stacking contexts (`overflow`, `clip-path`, `transform`). + * - `non-modal`: opened via `showPopover()` with `popover="manual"`, entering + * the browser top layer while keeping open/close fully React-controlled. */ modalType: DialogModalType; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx index 1f81afb1b5a30a..85fed63647a422 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx @@ -1,7 +1,6 @@ /** @jsxRuntime automatic */ /** @jsxImportSource @fluentui/react-jsx-runtime */ -import { Portal } from '@fluentui/react-portal'; import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { DialogSurfaceContext } from '../dialogContext'; @@ -12,11 +11,10 @@ import type { DialogSurfaceSlots, DialogSurfaceState } from './DialogSurface.typ * Returns null when the dialog is closed and unmountOnClose is true. * Provides DialogSurfaceContext=true so DialogTrigger inside defaults to action="close". * - * Non-modal dialogs are rendered via a React portal into `document.body`. - * Unlike `showModal()`, `dialog.show()` does not enter the browser top layer, so the - * element is still subject to ancestor `overflow`, `clip-path`, and `transform` - * stacking constraints. Portalling to body moves it outside any such container. - * React context (including DialogContext) is preserved across portals. + * DialogSurface is always rendered inline. For `modal`/`alert`, `` + * enters the browser top layer. For `non-modal`, the surface uses the native + * popover API (`popover="manual"` + `showPopover()`), which promotes it to the + * top layer without enabling native light-dismiss. */ export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | null => { if (!state.shouldRender) { @@ -31,5 +29,5 @@ export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | nul ); - return state.modalType === 'non-modal' ? {content} : content; + return content; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts index 51a6bae3601b69..54459461f3304b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts @@ -14,58 +14,47 @@ import { stringifyDataAttribute } from '../../../utils'; import { lockDocumentScroll, unlockDocumentScroll } from '../utils/scroll'; import type { DialogSurfaceProps, DialogSurfaceState } from './DialogSurface.types'; +const SUPPORTS_POPOVER_OPEN_SELECTOR = + typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); + +type ToggleEvent = Event & { newState?: 'open' | 'closed' }; + /** * Create the state required to render DialogSurface. - * - * Manages the native HTML `` element lifecycle: - * - Calls `showModal()` for modal/alert dialogs (native focus trap + `::backdrop`) - * - Calls `show()` for non-modal dialogs - * - Calls `close()` when the dialog should close - * - * Focus management is fully delegated to the browser: - * - On open, the browser's native dialog focusing steps move focus to the first - * focusable descendant (or the dialog itself if none). - * - On close, the browser restores focus to the element that was focused before - * `showModal()` / `show()` ran. - * - * When `unmountOnClose` is true, the DOM unmount is deferred by one render after - * `open` flips to false so that `dialog.close()` can run on the still-connected - * element — the browser only performs its close-time focus restoration when the - * element is in the document. - * - * `useIsomorphicLayoutEffect` is used for open/close so that `showModal()` runs - * synchronously after the DOM is updated but before the browser paints. This prevents - * a frame where the dialog element is in the DOM but not yet in the top layer. + * Uses native `` behavior: + * - modal/alert => `showModal()` + * - non-modal => `showPopover()` (fallback to `show()`) + * - close => `close()`/`hidePopover()` * * @param props - props from this instance of DialogSurface * @param ref - reference to root HTMLDialogElement of DialogSurface */ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref): DialogSurfaceState => { const { open, modalType, unmountOnClose, requestOpenChange, dialogTitleId } = useDialogContext(); - const { targetDocument } = useFluent(); // Ensure we're in a Fluent context, which provides SSR support for useIsomorphicLayoutEffect + const { targetDocument } = useFluent(); const dialogRef = React.useRef(null); + const previouslyFocusedElement = React.useRef(null); const mergedRef = useMergedRefs(ref, dialogRef); - // Keeps the element rendered for one extra frame after `open` flips to - // false so the effect can call `dialog.close()` on a connected element — which is - // what triggers the browser's native focus-restoration to `previouslyFocusedElement`. - // Irrelevant when `unmountOnClose` is false (the element is always in the DOM). + // Keep the element mounted one extra render so native close can run while connected. const [shouldRender, setShouldRender] = React.useState(open || !unmountOnClose); - // Derived-state-during-render: when the dialog is asked to open again after an - // unmount, re-render immediately so the element is in the DOM before the layout - // effect runs (and before the browser paints). + // Ensure the element exists before open side-effects run. if (open && !shouldRender) { setShouldRender(true); } - // Main effect: open/close the native dialog and suppress native Escape. - // - // Cancel (Escape) listener is co-located here — not in a separate [] effect — because - // with unmountOnClose=true the element unmounts when closed and re-mounts when - // opened again. A [] effect only runs once and would miss elements that mount after the - // initial render. + const handleToggle = useEventCallback((event: Event) => { + const toggle = event as ToggleEvent; + const nextOpen = toggle.newState === 'open'; + + if (nextOpen !== open) { + requestOpenChange({ type: 'surfaceToggle', open: nextOpen, event }); + } + }); + + // Open/close native dialog/popover and keep listeners in sync with mounted element. useIsomorphicLayoutEffect(() => { const dialog = dialogRef.current; if (!dialog) { @@ -73,10 +62,22 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref before React re-renders, - // and a controlled open={true} would fight the browser trying to reopen it. - const handleCancel = (event: Event) => event.preventDefault(); - dialog.addEventListener('cancel', handleCancel); + if (modalType === 'non-modal') { + previouslyFocusedElement.current = targetDocument?.activeElement as HTMLElement | null; + } + + let handleCancel: ((event: Event) => void) | undefined; + if (modalType !== 'non-modal') { + // Let React own close state; prevent native Escape auto-close. + handleCancel = (event: Event) => event.preventDefault(); + dialog.addEventListener('cancel', handleCancel); + } - if (!dialog.open) { - if (modalType !== 'non-modal') { + if (modalType !== 'non-modal') { + if (!dialog.open) { dialog.showModal(); - } else { - dialog.show(); } + } else if (typeof dialog.showPopover === 'function') { + const isPopoverOpen = SUPPORTS_POPOVER_OPEN_SELECTOR && dialog.matches(':popover-open'); + if (!isPopoverOpen) { + dialog.showPopover(); + } + + dialog.addEventListener('toggle', handleToggle); + } else if (!dialog.open) { + dialog.show(); } return () => { - dialog.removeEventListener('cancel', handleCancel); + if (handleCancel) { + dialog.removeEventListener('cancel', handleCancel); + } + dialog.removeEventListener('toggle', handleToggle); if (shouldLockScroll) { unlockDocumentScroll(targetDocument); } }; - }, [open, modalType, targetDocument, unmountOnClose]); + }, [open, modalType, targetDocument, unmountOnClose, handleToggle]); const handleKeyDown = useEventCallback((event: React.KeyboardEvent) => { props.onKeyDown?.(event); @@ -119,11 +134,29 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (firstFocusable) { + event.preventDefault(); + firstFocusable.focus(); + } } }); - // Detect backdrop click: the native element receives click events when - // the user clicks the backdrop (outside the dialog content bounding rect). + // Backdrop click is detected by checking clicks outside the dialog rect. const handleClick = useEventCallback((event: React.MouseEvent) => { props.onClick?.(event); if (modalType !== 'modal' || event.isDefaultPrevented()) { @@ -154,17 +187,15 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref already has implicit role="dialog" role: modalType === 'alert' ? 'alertdialog' : undefined, - // aria-modal is set implicitly by showModal(), but explicit is more robust for AT 'aria-modal': modalType !== 'non-modal' ? true : undefined, - // Point to DialogTitle id for accessible name 'aria-labelledby': props['aria-label'] ? undefined : dialogTitleId || undefined, ...props, tabIndex: -1, ref: mergedRef, onKeyDown: handleKeyDown, onClick: handleClick, + popover: modalType === 'non-modal' ? ('manual' as const) : undefined, 'data-open': stringifyDataAttribute(open), 'data-modal-type': modalType, }), diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/dialogContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/dialogContext.ts index 23524bf368f42c..4f688b90da5835 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/dialogContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/dialogContext.ts @@ -8,6 +8,7 @@ export type DialogModalType = 'modal' | 'non-modal' | 'alert'; export type DialogOpenChangeData = | { type: 'escapeKeyDown'; open: boolean; event: React.KeyboardEvent } | { type: 'backdropClick'; open: boolean; event: React.MouseEvent } + | { type: 'surfaceToggle'; open: boolean; event: Event } | { type: 'triggerClick'; open: boolean; event: React.MouseEvent }; export type DialogContextValue = { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/useDialog.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/useDialog.ts index 1323f946ba0602..b50a50de9477c4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/useDialog.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/useDialog.ts @@ -30,7 +30,15 @@ export const useDialog = (props: DialogProps): DialogState => { const requestOpenChange = useEventCallback((data: DialogOpenChangeData) => { onOpenChange?.(data.event, data); - if (!data.event.isDefaultPrevented()) { + + const syntheticEvent = data.event as React.SyntheticEvent; + + const isDefaultPrevented = + typeof syntheticEvent.isDefaultPrevented === 'function' + ? syntheticEvent.isDefaultPrevented() + : data.event.defaultPrevented; + + if (!isDefaultPrevented) { setOpen(data.open); } }); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css b/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css index 4d28698556bb80..ab3b979d166d99 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css @@ -13,7 +13,7 @@ box-shadow: var(--shadow-5); } -.surface::backdrop { +.surface:modal::backdrop { background: rgba(10, 10, 10, 0.4); backdrop-filter: blur(2px); }