Skip to content

Commit 2f596df

Browse files
committed
feat(react-headless-components-preview): move headless overlays onto useTopLayer
1 parent 78b707d commit 2f596df

13 files changed

Lines changed: 195 additions & 452 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 headless overlays onto useTopLayer",
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/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+
* Lifecycle phase from `useTopLayer`.
49+
*/
50+
phase: TopLayerPhase;
4651
};

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

Lines changed: 24 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,13 @@ import {
1212
import { useDialogContext } from '../dialogContext';
1313
import { toDataAttributeValue } from '../../../utils';
1414
import { lockDocumentScroll, unlockDocumentScroll } from '../utils/scroll';
15+
import { useTopLayer } from '../../../hooks/useTopLayer/useTopLayer';
1516
import type { DialogSurfaceProps, DialogSurfaceState } from './DialogSurface.types';
1617

17-
const SUPPORTS_POPOVER_OPEN_SELECTOR =
18-
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)');
19-
20-
type ToggleEvent = Event & { newState?: 'open' | 'closed' };
21-
2218
/**
2319
* Create the state required to render DialogSurface.
24-
* Uses native `<dialog>` behavior:
25-
* - modal/alert => `showModal()`
26-
* - non-modal => `showPopover()` (fallback to `show()`)
27-
* - close => `close()`/`hidePopover()`
20+
* Show/hide, focus restore and exit come from `useTopLayer`: `showModal()` for modal and alert, `popover="manual"`
21+
* for non-modal.
2822
*
2923
* @param props - props from this instance of DialogSurface
3024
* @param ref - reference to root HTMLDialogElement of DialogSurface
@@ -34,133 +28,37 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref<HTMLD
3428
const { targetDocument } = useFluent();
3529

3630
const dialogRef = React.useRef<HTMLDialogElement>(null);
37-
const previouslyFocusedElement = React.useRef<HTMLElement | null>(null);
3831
const mergedRef = useMergedRefs(ref, dialogRef);
3932

40-
// Keep the element connected for native close and consumer-defined exit animations.
41-
const [shouldRender, setShouldRender] = React.useState(open || !unmountOnClose);
42-
43-
// Ensure the element exists before open side-effects run.
44-
if ((open || !unmountOnClose) && !shouldRender) {
45-
setShouldRender(true);
46-
}
47-
48-
const handleToggle = useEventCallback((event: Event) => {
49-
const toggle = event as ToggleEvent;
50-
const nextOpen = toggle.newState === 'open';
33+
const isNonModal = modalType === 'non-modal';
5134

52-
if (nextOpen !== open) {
53-
requestOpenChange({ type: 'surfaceToggle', open: nextOpen, event });
54-
}
35+
// A `popovertarget` invoker can toggle a non-modal surface; it is reported as `surfaceToggle`.
36+
const { shouldRender, phase } = useTopLayer(dialogRef, {
37+
open,
38+
mode: isNonModal ? 'manual' : 'modal',
39+
unmountOnClose,
40+
// `onOpenChange` can veto Escape, so the browser must not close the dialog first.
41+
preventEscapeClose: true,
42+
onOpen: isNonModal ? event => requestOpenChange({ type: 'surfaceToggle', open: true, event }) : undefined,
43+
onClose: isNonModal ? ({ event }) => requestOpenChange({ type: 'surfaceToggle', open: false, event }) : undefined,
5544
});
5645

57-
// Open/close native dialog/popover and keep listeners in sync with mounted element.
5846
useIsomorphicLayoutEffect(() => {
59-
const dialog = dialogRef.current;
60-
if (!dialog) {
61-
return;
62-
}
63-
64-
if (!open) {
65-
if (modalType === 'non-modal') {
66-
if (typeof dialog.hidePopover === 'function') {
67-
const isPopoverOpen = SUPPORTS_POPOVER_OPEN_SELECTOR && dialog.matches(':popover-open');
68-
if (isPopoverOpen) {
69-
dialog.hidePopover();
70-
}
71-
} else if (dialog.open) {
72-
dialog.close();
73-
}
74-
75-
const elementToFocus = previouslyFocusedElement.current;
76-
if (elementToFocus && targetDocument?.contains(elementToFocus)) {
77-
elementToFocus.focus();
78-
}
79-
previouslyFocusedElement.current = null;
80-
} else if (dialog.open) {
81-
dialog.close();
82-
}
83-
if (unmountOnClose) {
84-
// subtree includes ::backdrop; filtering the target excludes unrelated child animations.
85-
const animations = (dialog.getAnimations?.({ subtree: true }) ?? []).filter(animation => {
86-
const effect = animation.effect;
87-
return (
88-
effect &&
89-
'target' in effect &&
90-
effect.target === dialog &&
91-
animation.playState === 'running' &&
92-
Number.isFinite(effect.getComputedTiming().endTime)
93-
);
94-
});
95-
96-
if (animations.length === 0) {
97-
setShouldRender(false);
98-
} else {
99-
let cancelled = false;
100-
let remaining = animations.length;
101-
// Cancellation rejects finished, but still completes the exit lifecycle.
102-
const handleAnimationFinished = () => {
103-
if (!cancelled && --remaining === 0) {
104-
setShouldRender(false);
105-
}
106-
};
107-
animations.forEach(animation => {
108-
animation.finished.then(handleAnimationFinished, handleAnimationFinished);
109-
});
110-
111-
return () => {
112-
cancelled = true;
113-
};
114-
}
115-
}
47+
if (!open || modalType === 'non-modal' || !targetDocument) {
11648
return;
11749
}
118-
119-
const shouldLockScroll = modalType !== 'non-modal' && !!targetDocument;
120-
if (shouldLockScroll) {
121-
lockDocumentScroll(targetDocument);
122-
}
123-
124-
if (modalType === 'non-modal') {
125-
previouslyFocusedElement.current = targetDocument?.activeElement as HTMLElement | null;
126-
}
127-
128-
let handleCancel: ((event: Event) => void) | undefined;
129-
if (modalType !== 'non-modal') {
130-
// Let React own close state; prevent native Escape auto-close.
131-
handleCancel = (event: Event) => event.preventDefault();
132-
dialog.addEventListener('cancel', handleCancel);
133-
}
134-
135-
if (modalType !== 'non-modal') {
136-
if (!dialog.open) {
137-
dialog.showModal();
138-
}
139-
} else if (typeof dialog.showPopover === 'function') {
140-
const isPopoverOpen = SUPPORTS_POPOVER_OPEN_SELECTOR && dialog.matches(':popover-open');
141-
if (!isPopoverOpen) {
142-
dialog.showPopover();
143-
}
144-
145-
dialog.addEventListener('toggle', handleToggle);
146-
} else if (!dialog.open) {
147-
dialog.show();
148-
}
149-
150-
return () => {
151-
if (handleCancel) {
152-
dialog.removeEventListener('cancel', handleCancel);
153-
}
154-
dialog.removeEventListener('toggle', handleToggle);
155-
if (shouldLockScroll) {
156-
unlockDocumentScroll(targetDocument);
157-
}
158-
};
159-
}, [open, modalType, targetDocument, unmountOnClose, handleToggle]);
50+
lockDocumentScroll(targetDocument);
51+
return () => unlockDocumentScroll(targetDocument);
52+
}, [open, modalType, targetDocument]);
16053

16154
const handleKeyDown = useEventCallback((event: React.KeyboardEvent<HTMLDialogElement>) => {
16255
props.onKeyDown?.(event);
163-
if (event.key === 'Escape' && !event.isDefaultPrevented()) {
56+
// Escape inside a nested popover or dialog closes that layer first.
57+
if (
58+
event.key === 'Escape' &&
59+
!event.isDefaultPrevented() &&
60+
(event.target as Element).closest('[popover], dialog') === event.currentTarget
61+
) {
16462
requestOpenChange({ type: 'escapeKeyDown', open: false, event });
16563
// Stop propagation to avoid closing parent dialogs simultaneously.
16664
event.stopPropagation();
@@ -215,6 +113,7 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref<HTMLD
215113
unmountOnClose,
216114
modalType,
217115
shouldRender,
116+
phase,
218117
root: slot.always(
219118
getIntrinsicElementProps('dialog', {
220119
role: modalType === 'alert' ? 'alertdialog' : undefined,

‎packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts‎

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {
99
useEventCallback,
1010
slot,
1111
isResolvedShorthand,
12-
useIsomorphicLayoutEffect,
1312
useMergedRefs,
1413
} from '@fluentui/react-utilities';
1514
import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities';
1615
import type { DropdownState } from './Dropdown.types';
1716
import { Listbox } from './Listbox';
17+
import { useTopLayer } from '../../hooks/useTopLayer';
1818
import type { ListboxProps } from './Listbox';
1919

2020
export type UseListboxSlotState = Pick<DropdownState, 'multiselect' | 'open' | 'setOpen'>;
@@ -101,45 +101,15 @@ export function useListboxSlot(
101101
listboxSlot.onClick = onClick;
102102
}
103103

104-
const onListboxToggle = useEventCallback((event: Event & { newState?: 'open' | 'closed' }) => {
105-
const nextOpen = event.newState === 'open';
106-
if (nextOpen === open) {
107-
return;
108-
}
109-
setOpen(event as unknown as DropdownOpenEvents, nextOpen);
104+
// Focus stays on the trigger (`aria-activedescendant`). The render function unmounts the listbox when closed.
105+
useTopLayer(popoverRef, {
106+
open,
107+
mode: 'auto',
108+
unmountOnClose: false,
109+
restoreFocus: false,
110+
onClose: ({ event }) => setOpen(event as unknown as DropdownOpenEvents, false),
111+
onOpen: event => setOpen(event as unknown as DropdownOpenEvents, true),
110112
});
111113

112-
useIsomorphicLayoutEffect(() => {
113-
const el = listboxRef.current;
114-
if (!el) {
115-
return;
116-
}
117-
118-
el.addEventListener('toggle', onListboxToggle);
119-
120-
try {
121-
if (open) {
122-
el.showPopover();
123-
} else if (el.matches(':popover-open')) {
124-
el.hidePopover();
125-
}
126-
} catch (error) {
127-
if (process.env.NODE_ENV === 'development') {
128-
// eslint-disable-next-line no-console
129-
console.warn(
130-
[
131-
'Popover API is not supported in this browser, and the listbox will not work correctly.',
132-
'Please include a popover polyfill for better browser support.',
133-
].join(' '),
134-
{ error },
135-
);
136-
}
137-
}
138-
139-
return () => {
140-
el.removeEventListener('toggle', onListboxToggle);
141-
};
142-
}, [listboxRef, open, onListboxToggle]);
143-
144114
return listboxSlot;
145115
}

0 commit comments

Comments
 (0)