You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 25fbb78
Browse filesBrowse the repository at this point in the historyBrowse files
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.
"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",
Copy file name to clipboardExpand all lines: packages/react-components/react-headless-components-preview/library/docs/popover-spec.md
+13-10Lines changed: 13 additions & 10 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -150,18 +150,19 @@ The role on the surface depends on `trapFocus`:
150
150
151
151
### Focus management
152
152
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.
154
154
155
155
-**`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.
157
158
158
159
#### Specifying initial focus
159
160
160
161
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.
161
162
162
163
#### Cancel handling (modal Esc)
163
164
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`.
165
166
166
167
### Labeling
167
168
@@ -232,13 +233,15 @@ This implementation defers opening until `pointerup`, which avoids opening durin
| 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. |
| 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).
242
245
243
246
`usePopover` attaches a `toggle` listener on the surface (re-attached every time the surface mounts) that mirrors browser-driven dismissal:
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.
- Showing a modal closes open `auto` popovers. Content that must sit above a modal (toasts) must be shown after it.
135
135
- 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`).
136
136
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
+
137
146
## Decisions
138
147
139
148
**React is told about every change the browser makes, and never about its own.**
140
149
141
150
- 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.
142
151
- 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.
144
153
145
154
**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.
146
155
147
156
**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.
| 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 |
Copy file name to clipboardExpand all lines: packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts
0 commit comments