Skip to content

Commit 25fbb78

Browse files
committed
feat(react-headless-components-preview): move headless overlays onto useTopLayer
Popover (TeachingPopover, InfoLabel), DialogSurface (Drawer), Tooltip, MenuPopover, the Combobox/Dropdown/TagPicker listbox and Toaster now use `useTopLayer` instead of calling `showPopover` / `showModal` themselves. - Popover keeps master's lifecycle: `usePopover` drives `contentRef`, the surface stays mounted until the browser reports the close (now also until exit animations finish), and focus returns to the trigger when it was inside. - Dialog: modal dialogs keep blocking native Escape (`preventEscapeClose`) so `onOpenChange` can veto; non-modal dialogs still report `popovertarget` toggles as `surfaceToggle`. - Tooltip uses `hintFallback: 'manual'`, so it never closes an open popover in browsers without `popover="hint"`. - Menu, listbox and toasts keep their own focus handling. Visible changes: modal Dialog, trapFocus Popover and Drawer wrap Tab; a non-modal Dialog moves focus into itself on open and restores focus only when it was inside; Escape inside a nested popover no longer closes the surrounding DialogSurface.
1 parent 69aad15 commit 25fbb78

14 files changed

Lines changed: 235 additions & 456 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: move Popover, Dialog, Tooltip, Menu, the Combobox/Dropdown/TagPicker listbox and Toaster onto useTopLayer. Popover also waits for exit animations; modal Dialog, trapFocus Popover and Drawer wrap Tab; non-modal Dialog moves focus into itself on open and only restores focus when it was inside",
4+
"packageName": "@fluentui/react-headless-components-preview",
5+
"email": "vgenaev@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

‎packages/react-components/react-headless-components-preview/library/docs/popover-spec.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,19 @@ The role on the surface depends on `trapFocus`:
150150

151151
### Focus management
152152

153-
Focus management is delegated to the platform; the component adds no React-side focus trap, focus finder, or restoration logic.
153+
The surface's top-layer lifecycle and focus handling come from the shared `useTopLayer` hook.
154154

155155
- **`trapFocus={false}` (default).** No autofocus on open unless a child element has the HTML `autofocus` attribute (in which case the popover-side focusing steps focus it per the popover spec). No focus trap — Tab follows the document's normal tab order. Light dismiss is browser-managed.
156-
- **`trapFocus={true}`.** The browser opens the surface via `dialog.showModal()` and runs the modal `<dialog>` focusing steps: focus moves to the first sequentially-focusable descendant (or to a child carrying `autofocus`), the rest of the page is **inert**, and Tab cycles inside the surface. On close, `dialog.close()` restores focus to whatever element was focused when `showModal()` ran — typically the trigger.
156+
- **`trapFocus={true}`.** The browser opens the surface via `dialog.showModal()` and runs the modal `<dialog>` focusing steps: focus moves to the first sequentially-focusable descendant (or to a child carrying `autofocus`) and the rest of the page is **inert**. Tab and Shift+Tab wrap inside the surface instead of leaving through the browser chrome.
157+
- **Restore.** The browser restores focus on close. When focus was inside the surface as it started closing and is now lost to `<body>` or still inside it, focus moves to the trigger. Focus the user moved elsewhere is left alone.
157158

158159
#### Specifying initial focus
159160

160161
Consumers control which child receives initial focus via the standard React `autoFocus` attribute on the desired child JSX element. There is no `initialFocusRef` prop — the HTML attribute already covers it for both show modes.
161162

162163
#### Cancel handling (modal Esc)
163164

164-
When `trapFocus={true}`, Escape fires the native `cancel` event on the dialog. `usePopover` listens for `cancel`, calls `event.preventDefault()` (so the browser doesn't close the dialog before React re-renders), and routes through `setOpen(false)`. The subsequent close — driven by `open` flipping to `false` — runs `dialog.close()`, which performs the spec-mandated focus restoration to the trigger. This keeps controlled consumers authoritative without losing the platform's restore behavior.
165+
When `trapFocus={true}`, Escape fires the native `cancel` event and the browser closes the dialog, restoring focus to the trigger. The surface reports the native `close` event, so `onOpenChange` fires with `type: 'close'` and `open: false`.
165166

166167
### Labeling
167168

@@ -232,13 +233,15 @@ This implementation defers opening until `pointerup`, which avoids opening durin
232233

233234
### Dismiss paths (browser-driven, then mirrored)
234235

235-
| Source | Mechanism |
236-
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
237-
| Escape | Browser dismisses the topmost open popover per the HTML Popover light-dismiss algorithm. |
238-
| Click outside | Browser dismisses any popover whose ancestor chain doesn't contain the click target. |
239-
| Open an unrelated `popover="auto"` peer | Browser dismisses the existing root chain — only one auto-popover root can be open at a time. |
240-
| Hover-leave (`openOnHover`) | React-side: `setOpen(false)` after `mouseLeaveDelay` ms with the pointer outside both trigger and surface. `setOpen(false)` unmounts the surface in JSX. |
241-
| Programmatic | Consumer sets `open={false}`. React unmounts the surface; the browser implicitly closes the popover when the element leaves the DOM. |
236+
| Source | Mechanism |
237+
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
238+
| Escape | Browser dismisses the topmost open popover per the HTML Popover light-dismiss algorithm. |
239+
| Click outside | Browser dismisses any popover whose ancestor chain doesn't contain the click target. |
240+
| Open an unrelated `popover="auto"` peer | Browser dismisses the existing root chain — only one auto-popover root can be open at a time. |
241+
| Hover-leave (`openOnHover`) | React-side: `setOpen(false)` after `mouseLeaveDelay` ms with the pointer outside both trigger and surface. The surface is hidden, then unmounted after its exit animations. |
242+
| Programmatic | Consumer sets `open={false}`. The surface is hidden with `hidePopover()` / `close()`, then unmounted after its exit animations. |
243+
244+
While the surface exits it keeps `data-phase="exiting"`, loses `data-open`, and is `inert` and `aria-hidden`. Exit styles must keep it displayed and in the top layer (`transition: display … allow-discrete, overlay … allow-discrete`, or `display` keyframes).
242245

243246
`usePopover` attaches a `toggle` listener on the surface (re-attached every time the surface mounts) that mirrors browser-driven dismissal:
244247

‎packages/react-components/react-headless-components-preview/library/docs/top-layer-spec.md‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Exported from `@fluentui/react-headless-components-preview/top-layer`.
1818
| `useTopLayer` | top-layer lifecycle, focus and close reporting for an element you own |
1919
| `getTopLayerTriggerProps` | trigger ARIA attributes |
2020

21-
There are no ready-made components. The Concepts/Top Layer stories show recipes for a menu, a modal dialog and a hint.
21+
There are no ready-made components: `Popover` and `Dialog` are built on the same hook. The Concepts/Top Layer stories show recipes for a menu, a modal dialog and a hint.
2222

2323
### Terms
2424

@@ -134,14 +134,41 @@ Tabbable elements exclude `tabindex="-1"`, disabled, `aria-disabled="true"`, `ar
134134
- Showing a modal closes open `auto` popovers. Content that must sit above a modal (toasts) must be shown after it.
135135
- Popovers opened without user activation between them share a close watcher, so one Escape closes them together. Tests must use trusted input (`cypress-real-events`).
136136

137+
## Consumers
138+
139+
- **Popover / TeachingPopover / InfoLabel.** `usePopover` calls `useTopLayer` on `contentRef` with `mode: trapFocus ? 'modal' : 'auto'` and `restoreFocusTarget: triggerRef`, and routes `onClose` / `onOpen` into its open state. `shouldRenderSurface` comes from the hook. Any element given `contentRef` is driven the same way; a custom surface that does not use `contentRef` is mounted only while open.
140+
- **Dialog / Drawer.** `DialogSurface` calls `useTopLayer` with `mode: 'manual'` (non-modal) or `'modal'`. Escape and backdrop clicks stay in the surface's React handlers so `onOpenChange` keeps its event types; Escape from inside a nested popover or dialog is left to that layer. A non-modal surface toggled by a `popovertarget` invoker is reported as `surfaceToggle`.
141+
- **Tooltip.** `mode: 'hint'` with `hintFallback: 'manual'`, so showing a tooltip never closes an open `auto` popover in browsers without `hint`. The content stays mounted (`unmountOnClose: false`) so `aria-labelledby` / `aria-describedby` always resolve. No focus handling.
142+
- **Menu.** `MenuPopover` stays mounted and uses `mode: 'auto'`. It still marks the first item `autofocus` before the popover is shown, and `useMenu` keeps owning focus restore (`restoreFocus: false`). Browser closes are reported as `clickOutside`, as before.
143+
- **Combobox / Dropdown / TagPicker listbox.** `mode: 'auto'`, rendered only while open, focus stays on the trigger (no initial focus, `restoreFocus: false`). Browser opens and closes are synced through `setOpen`.
144+
- **Toaster.** Each toast position is a `manual` popover shown while it has toasts and unmounted when it has none. Toasts own their focus restore.
145+
137146
## Decisions
138147

139148
**React is told about every change the browser makes, and never about its own.**
140149

141150
- Browser closes (Escape, light dismiss, `<form method="dialog">`) are reported through `onClose`; browser opens (`popovertarget`, `commandfor`) through `onOpen`, or ignored with a dev warning without it.
142151
- Programmatic closes (`open` becoming false) are never reported.
143-
- Escape on a modal follows the platform: the browser closes the dialog and `onClose` reports it. `preventEscapeClose` keeps the dialog open instead, for consumers that let the user veto a close.
152+
- Escape on a modal follows the platform: the browser closes the dialog and `onClose` reports it. `preventEscapeClose` keeps the dialog open instead, for consumers that let the user veto a close. `DialogSurface` uses it, because `onOpenChange` can be default-prevented.
144153

145154
**Focus restore defers to the browser.** The hook only covers what the browser leaves undone, and only when focus was inside the element as it started closing.
146155

147156
**Tab wraps only in modal dialogs.** A non-modal dialog that trapped Tab would keep keyboard users out of the page it sits next to.
157+
158+
## Changes from master
159+
160+
Observable differences for the components moved onto `useTopLayer`. Everything else is unchanged.
161+
162+
| Area | Master | Now |
163+
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164+
| Popover exit | surface unmounted once the browser reported the close | unchanged, and it also waits for running exit animations, like `DialogSurface` |
165+
| Exiting element | — | `inert` and `aria-hidden="true"` while it exits (Popover and Dialog) |
166+
| Modal Tab | browser default: Tab can leave a modal through the browser chrome | Tab wraps inside modal Dialog, `trapFocus` Popover and Drawer; a radio group counts as one stop |
167+
| Non-modal Dialog open | focus stays where it was, unless a child has `autofocus` | focus moves to `[autofocus]`, else the first focusable element |
168+
| Non-modal Dialog close | focus always returned to the element focused before opening, even if the user moved it away | returned only when focus was inside as the dialog closed and is still inside it or on `<body>` |
169+
| Escape in a popover or dialog nested in a `DialogSurface` | the outer dialog closed too, unless the inner handler stopped the event | only the inner one closes |
170+
| `popover="hint"` | Tooltip set `hint` directly | the hook switches it to `auto` after mount in browsers without `hint`, without an SSR mismatch |
171+
| Hidden Tooltip | `display: none` | `display: none`, plus `inert` and `aria-hidden="true"` once it has been shown and hidden again; `aria-labelledby` / `aria-describedby` still resolve, because directly referenced hidden content counts for the accessible name |
172+
| Custom Popover surface | mounted only while open | unchanged |
173+
| Popover `trapFocus` Escape | browser closes the dialog, `onOpenChange` `type: 'close'` | unchanged |
174+
| Native opens (`popovertarget`) | non-modal Dialog synced as `surfaceToggle`; Popover unreachable while closed | unchanged |

‎packages/react-components/react-headless-components-preview/library/etc/dialog.api.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export type DialogSurfaceState = ComponentState<DialogSurfaceSlots> & {
147147
unmountOnClose: boolean;
148148
modalType: DialogModalType;
149149
shouldRender: boolean;
150+
phase: TopLayerPhase;
150151
};
151152

152153
// @public

‎packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,4 +517,32 @@ describe('Dialog', () => {
517517
cy.get('dialog').should('not.exist');
518518
});
519519
});
520+
521+
describe('native invokers', () => {
522+
it('syncs a non-modal surface toggled by popovertarget into the open state', () => {
523+
const types: string[] = [];
524+
mount(
525+
<>
526+
<button id="invoker" popoverTarget="native-dialog">
527+
Toggle
528+
</button>
529+
<Dialog
530+
modalType="non-modal"
531+
unmountOnClose={false}
532+
onOpenChange={(_, data) => types.push(`${data.type}:${data.open}`)}
533+
>
534+
<DialogSurface id="native-dialog" aria-label="Native">
535+
<button>Inside</button>
536+
</DialogSurface>
537+
</Dialog>
538+
</>,
539+
);
540+
541+
cy.get('#invoker').realClick();
542+
cy.get('#native-dialog').should('match', ':popover-open').and('have.attr', 'data-open');
543+
cy.get('#invoker').realClick();
544+
cy.get('#native-dialog').should('not.match', ':popover-open').and('not.have.attr', 'data-open');
545+
cy.then(() => expect(types).to.deep.equal(['surfaceToggle:true', 'surfaceToggle:false']));
546+
});
547+
});
520548
});

‎packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
22
import type { DialogModalType } from '../dialogContext';
3+
import type { TopLayerPhase } from '../../../hooks/useTopLayer/TopLayer.types';
34

45
export type DialogSurfaceSlots = {
56
/**
@@ -43,4 +44,8 @@ export type DialogSurfaceState = ComponentState<DialogSurfaceSlots> & {
4344
* consumer CSS controls whether the surface remains visible during its exit.
4445
*/
4546
shouldRender: boolean;
47+
/**
48+
* Top-layer lifecycle phase of the surface.
49+
*/
50+
phase: TopLayerPhase;
4651
};

0 commit comments

Comments
 (0)