From c483fa49f8758d9168170adf99607317056e2e16 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Fri, 16 Oct 2026 18:16:43 +0200 Subject: [PATCH 01/12] feat(react-headless-components-preview): add opt-in Popover focus-exit dismissal Preserve destination focus when a non-modal Popover's logical focus boundary is exited. Keep the behavior opt-in and honor controlled state, native closing, and existing modal semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44bec1f8-d9c2-4333-a285-7270b37fe5da --- ...-a73dd995-4801-4e2d-a38e-22b587c50e43.json | 6 + .../library/docs/popover-spec.md | 41 +-- .../library/etc/popover.api.md | 3 +- .../library/etc/teaching-popover.api.md | 1 + .../src/components/Popover/Popover.cy.tsx | 247 ++++++++++++++++++ .../src/components/Popover/Popover.test.tsx | 246 ++++++++++++++++- .../src/components/Popover/Popover.types.ts | 12 + .../src/components/Popover/usePopover.ts | 59 +++++ .../stories/src/Popover/PopoverDescription.md | 2 + 9 files changed, 599 insertions(+), 18 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json diff --git a/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json new file mode 100644 index 0000000000000..45043f8471c82 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat: add opt-in non-modal Popover focus-exit dismissal", + "packageName": "@fluentui/react-headless-components-preview", + "email": "paulmardling@microsoft.com" +} diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index 5ae530391ed1a..0c521368b65ce 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -4,7 +4,7 @@ Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface elevates into the browser's **top layer** via the native HTML Popover API. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop. -Popover lets the browser manage dismissal: a non-modal surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React; close paths defer to the browser. When `trapFocus` is enabled, the default `` surface opens modally with `role="dialog"`. +Popover lets the browser manage light dismissal: a non-modal surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React. Hover-leave, programmatic close, and opt-in `closeOnFocusOutside` requests also flow through React before native closing. When `trapFocus` is enabled, the default `` surface opens modally with `role="dialog"`. ## Composition @@ -24,17 +24,18 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with ### `Popover` -| Prop | Type | Default | Description | -| ----------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. | -| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. | -| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). | -| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). | -| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. | -| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. | -| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. | -| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). | -| `trapFocus` | `boolean` | `false` | Open the popover as a modal via `HTMLDialogElement.showModal()`. See [Focus management](#focus-management). | +| Prop | Type | Default | Description | +| --------------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. | +| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. | +| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). | +| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). | +| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. | +| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. | +| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. | +| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). | +| `trapFocus` | `boolean` | `false` | Open the popover as a modal via `HTMLDialogElement.showModal()`. See [Focus management](#focus-management). | +| `closeOnFocusOutside` | `boolean` | `false` | Opt in to non-modal logical-boundary focus-exit dismissal. See [Opt-in focus-exit dismissal](#opt-in-focus-exit-dismissal). | ### `PopoverTrigger` @@ -150,11 +151,19 @@ The role on the surface depends on `trapFocus`: ### Focus management -Focus management is delegated to the platform; the component adds no React-side focus trap, focus finder, or restoration logic. +Native opening, trapping, and closing use the platform APIs, with the close lifecycle retaining the connected surface until native closing finishes. - **`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. - **`trapFocus={true}`.** The browser opens the surface via `dialog.showModal()` and runs the modal `` 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. +#### Opt-in focus-exit dismissal + +`closeOnFocusOutside` defaults to `false` and applies only when `trapFocus={false}`. When enabled, focus moving outside the logical boundary requests closing through `onOpenChange` with a native `focusin` event. Controlled consumers may accept or reject the request through `open`. Closing happens after destination focus is established, preserving focus on that destination rather than moving it back to the trigger. + +The boundary includes the trigger, surface, and their DOM, shadow, and Fluent virtual-parent descendants. Moving between those members, including back to the trigger, keeps the popover open. Unregistered React portals are not automatically owned descendants. A trigger focused at opening immediately establishes boundary focus, including for text-only surfaces. Hover or programmatic opening with focus elsewhere waits for focus to enter the boundary before an exit can dismiss. + +Window blur and an unknown destination (`relatedTarget={null}`) alone do not request dismissal. A subsequent confirmed focus move outside can dismiss; unrelated external focus movement on a popover that has never owned focus cannot. + #### Specifying initial focus 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. @@ -204,7 +213,7 @@ Arrow positioning is **consumer-owned CSS** keyed off `[data-placement]`. The ho ## Open / dismiss model -The surface is rendered with `popover="auto"`, so the **browser owns light dismiss**. React only owns _opening_ the surface (and unmounting the JSX once state flips closed); every close path goes through the browser first and is mirrored back into React via the surface's `toggle` event. +The surface is rendered with `popover="auto"`, so the **browser owns light dismiss**. React owns opening and explicit close requests, including opt-in focus-exit dismissal. React-driven closing calls `hidePopover()` while the surface remains connected, then unmounts after native closing. Browser-driven dismissal is mirrored back into React via the surface's `toggle` event. ### Open paths (React-driven) @@ -322,8 +331,8 @@ Firefox and Safari are implementing CSS Anchor Positioning; most features work b ## Notes -- **Auto mode is the only mode**: the surface always renders with `popover="auto"`, so the browser owns light dismiss and the popover-stack semantics. There is no manual-mode escape hatch — consumers that need React-driven dismiss timing or multiple unrelated popovers open at once should not use this component (or should fork the dismiss layer). +- **Auto mode is the only non-modal mode**: the surface renders with `popover="auto"`, so the browser owns light dismiss and the popover-stack semantics. Opt-in focus-exit dismissal adds a React-driven close request without replacing native light dismiss. There is no manual-mode escape hatch for intercepting native dismissal or keeping multiple unrelated popovers open at once. - **Nested popovers**: nesting is JSX-nesting. The browser's popover-stack treats the inner trigger's DOM descendancy of the outer surface as the ancestor signal — Escape closes only the topmost popover, click-outside dismisses the chain. -- **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface. Closing waits `mouseLeaveDelay` ms; this is the one close path that is React-driven (it unmounts the surface, which implicitly closes the native popover). +- **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface. Closing waits `mouseLeaveDelay` ms and follows the React-driven native-close lifecycle. - **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger. - **Positioning is CSS, not JS**: because placement computation is pushed to the browser, there's no JS layout loop and `positioning.updatePosition()` is a no-op. Consumers that need imperative retargeting use `positioning.setTarget(el)`. diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md index 4a4241a26ffc7..eca846d271c7a 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -25,7 +25,7 @@ export type OnOpenChangeData = EventData }; // @public -export type OpenPopoverEvents = MouseEvent | TouchEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent; +export type OpenPopoverEvents = MouseEvent | TouchEvent | FocusEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent; // @public export const Popover: { @@ -50,6 +50,7 @@ export type PopoverProps = { openOnHover?: boolean; openOnContext?: boolean; mouseLeaveDelay?: number; + closeOnFocusOutside?: boolean; positioning?: PositioningShorthand; withArrow?: boolean; id?: string; diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md index 36364e77a9828..784cab5224144 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -233,6 +233,7 @@ export type TeachingPopoverProps = { openOnHover?: boolean; openOnContext?: boolean; mouseLeaveDelay?: number; + closeOnFocusOutside?: boolean; positioning?: PositioningShorthand; withArrow?: boolean; id?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index 83d6cee83dc06..94c80ca5fcbd6 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -6,6 +6,7 @@ import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; import type { PopoverProps } from './Popover.types'; import { Tooltip } from '../Tooltip'; +import { setVirtualParent, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PositioningImperativeRef } from '@fluentui/react-positioning'; @@ -16,6 +17,252 @@ const mount = (element: JSXElement) => { const popoverTriggerSelector = '[aria-expanded]'; const popoverContentSelector = '[role="group"]'; +describe('opt-in focus-exit dismissal', () => { + const Example = ({ + textOnly = false, + mode = 'uncontrolled', + closeOnFocusOutside = true, + onOpenChange, + }: { + textOnly?: boolean; + mode?: 'uncontrolled' | 'accept' | 'reject'; + closeOnFocusOutside?: boolean; + onOpenChange?: PopoverProps['onOpenChange']; + }) => { + const [open, setOpen] = React.useState(false); + return ( + <> + + { + onOpenChange?.(event, data); + if (mode !== 'reject' || data.open) { + setOpen(data.open); + } + }} + > + + + + + {textOnly ? 'Surface' : } + + + + + ); + }; + + ([false, true] as const).forEach(textOnly => { + (['uncontrolled', 'accept', 'reject'] as const).forEach(mode => { + it(`preserves Tab destination and requests closing once (${textOnly ? 'text' : 'interactive'}, ${mode})`, () => { + mount(); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get(textOnly ? '[data-testid=trigger]' : '[data-testid=inside]') + .focus() + .realPress('Tab'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('[data-testid=surface]').should(mode === 'reject' ? 'be.visible' : 'not.exist'); + cy.get('@change').should('have.been.calledOnce'); + cy.get('@change').its('firstCall.args.1').should('include', { open: false, type: 'focusin' }); + if (mode === 'reject') { + cy.get('[data-testid=before]').focus(); + cy.get('@change').should('have.been.calledOnce'); + } + }); + }); + }); + + it('keeps reverse Tab to the trigger open, then closes on leaving the trigger', () => { + mount(); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress(['Shift', 'Tab']); + cy.get('[data-testid=trigger]').should('have.focus'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').should('not.have.been.called'); + cy.get('[data-testid=trigger]').realPress(['Shift', 'Tab']); + cy.get('[data-testid=before]').should('have.focus'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('@change').should('have.been.calledOnce'); + }); + + it('seeds boundary focus for a mouse-opened text-only popover', () => { + mount(); + cy.get('[data-testid=trigger]').realClick(); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('[data-testid=surface]').realPress('Tab'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('[data-testid=surface]').should('not.exist'); + }); + + it('does not change Tab-through behavior without opt-in', () => { + mount(); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress('Tab'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').should('not.have.been.called'); + }); + + it('seeds autofocus before listeners attach and preserves the Tab destination', () => { + mount( + <> + + + + + + + , + ); + cy.get('[data-testid=inside]').should('have.focus').realPress('Tab'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('@change').should('have.been.calledOnce'); + }); + + it('does not dismiss an initially external boundary until focus enters and leaves', () => { + mount( + <> + + + + + + + Surface + + + + , + ); + cy.get('[data-testid=before]').should('have.focus'); + cy.get('[data-testid=after]').focus(); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').should('not.have.been.called'); + cy.get('[data-testid=trigger]').focus(); + cy.get('[data-testid=after]').focus(); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + }); + + it('keeps Escape restoration and click-outside dismissal working with opt-in', () => { + mount(); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress('Escape'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=trigger]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + cy.get('[data-testid=trigger]').realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=after]').realClick(); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + }); + + it('does not close a parent when focus enters a nested surface or returns to its trigger', () => { + mount( + + + + + + + + + + + + + + + , + ); + cy.get('[data-testid=parent-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=parent-surface]').should('be.visible'); + cy.get('@parentChange').invoke('resetHistory'); + cy.get('[data-testid=child-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=child-surface]').should('be.visible'); + cy.get('[data-testid=child-inside]').focus().realPress(['Shift', 'Tab']); + cy.get('[data-testid=child-trigger]').should('have.focus'); + cy.get('[data-testid=parent-surface]').should('be.visible'); + cy.get('[data-testid=child-surface]').should('be.visible'); + cy.get('@parentChange').should('not.have.been.called'); + }); + + it('includes a virtual-parent portal and nested shadow control in the boundary', () => { + const OwnedControls = ({ surface }: { surface: React.RefObject }) => { + const [root, setRoot] = React.useState(null); + const portalRef = React.useRef(null); + const attachRoot = React.useCallback((host: HTMLDivElement | null) => { + if (host) { + setRoot(host.shadowRoot ?? host.attachShadow({ mode: 'open' })); + } + }, []); + useIsomorphicLayoutEffect(() => { + if (portalRef.current && surface.current) { + setVirtualParent(portalRef.current, surface.current); + } + }); + return ( + <> +
+ {root && createPortal(, root)} +
+ {createPortal( + , + surface.current?.ownerDocument.body ?? document.body, + )} + + ); + }; + const OwnedExample = () => { + const surface = React.useRef(null); + return ( + <> + + + + + + + + + ); + }; + mount(); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('[data-testid=inside]').focus(); + cy.get('[data-testid=owned-portal]').focus(); + cy.get('[data-testid=owned-shadow]').shadow().contains('button', 'Shadow control').focus(); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').should('not.have.been.called'); + cy.get('[data-testid=after]').focus(); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + }); +}); + describe('native surface lifecycle', () => { const Example = ({ trapFocus, preventOpening = false }: { trapFocus: boolean; preventOpening?: boolean }) => { const [open, setOpen] = React.useState(true); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx index 6dc5e47392e54..57dd6331e6027 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx @@ -1,13 +1,257 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { act, render, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { setVirtualParent } from '@fluentui/react-utilities'; import { Popover } from './Popover'; import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; import { usePopover } from './usePopover'; +import type { PopoverProps } from './Popover.types'; describe('Popover', () => { + describe('closeOnFocusOutside', () => { + const focus = (element: HTMLElement) => act(() => element.focus()); + const Example = ({ textOnly = false, ...props }: Partial & { textOnly?: boolean }) => ( + <> + + + + + + + {textOnly ? 'Surface' : } + {props.children} + + + + + ); + + it('preserves default Tab-through behavior without opting in', () => { + const onOpenChange = jest.fn(); + const { getByText } = render(); + focus(getByText('Inside')); + focus(getByText('After')); + expect(getByText('Inside')).toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('closes once on Tab out and preserves destination focus', async () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText } = render(); + focus(getByText('Inside')); + userEvent.tab(); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('After')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith( + expect.any(FocusEvent), + expect.objectContaining({ type: 'focusin', open: false }), + ); + }); + + it('keeps surface-to-trigger movement open, then closes on Shift+Tab outside', async () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText } = render(); + focus(getByText('Inside')); + userEvent.tab({ shift: true }); + expect(getByText('Trigger')).toHaveFocus(); + expect(onOpenChange).not.toHaveBeenCalled(); + userEvent.tab({ shift: true }); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('Before')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('seeds trigger focus when opening a text-only surface', () => { + const onOpenChange = jest.fn(); + const { getByText, rerender } = render(); + focus(getByText('Trigger')); + rerender(); + userEvent.tab(); + expect(getByText('After')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('seeds focus moved by native opening before the listener is attached', () => { + const onOpenChange = jest.fn(); + const { getByText } = render( + + + + + , + ); + const { getByText: getOutside } = render(); + expect(getByText('Autofocused')).toHaveFocus(); + focus(getOutside('Outside')); + expect(getOutside('Outside')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss initially external focus until the boundary has been entered', async () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText } = render(); + focus(getByText('Before')); + focus(getByText('After')); + expect(onOpenChange).not.toHaveBeenCalled(); + focus(getByText('Trigger')); + focus(getByText('After')); + await waitFor(() => expect(queryByText('Surface')).toBeNull()); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss hover opening when focus moves between unrelated outside controls', () => { + const onOpenChange = jest.fn(); + const { getByText } = render(); + focus(getByText('Before')); + fireEvent.mouseEnter(getByText('Trigger')); + onOpenChange.mockClear(); + focus(getByText('After')); + expect(getByText('Surface')).toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('allows controlled acceptance without duplicate native close callbacks', async () => { + const onOpenChange = jest.fn(); + const ControlledExample = () => { + const [open, setOpen] = React.useState(true); + return ( + { + onOpenChange(event, data); + setOpen(data.open); + }} + /> + ); + }; + const { getByText, queryByText } = render(); + focus(getByText('Inside')); + focus(getByText('After')); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('After')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('honors controlled rejection and only requests again after reentry', () => { + const onOpenChange = jest.fn(); + const { getByText } = render(); + focus(getByText('Inside')); + focus(getByText('After')); + expect(getByText('Inside').closest('[popover]')?.matches(':popover-open')).toBe(true); + expect(getByText('After')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + focus(getByText('Before')); + expect(onOpenChange).toHaveBeenCalledTimes(1); + focus(getByText('Inside')); + focus(getByText('After')); + expect(onOpenChange).toHaveBeenCalledTimes(2); + }); + + it('ignores unknown destinations and window blur without losing boundary ownership', async () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText, rerender } = render(); + const inside = getByText('Inside'); + focus(inside); + act(() => inside.blur()); + fireEvent.focusOut(inside, { relatedTarget: null }); + fireEvent(inside.ownerDocument.defaultView!, new Event('blur')); + expect(onOpenChange).not.toHaveBeenCalled(); + rerender(); + focus(getByText('After')); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('After')).toHaveFocus(); + }); + + it('does not change modal focus handling', () => { + const onOpenChange = jest.fn(); + const { getByText } = render(); + focus(getByText('Inside')); + focus(getByText('After')); + expect(getByText('Inside')).toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('keeps a parent open while entering and leaving a nested surface within its boundary', async () => { + const parentChange = jest.fn(); + const childChange = jest.fn(); + const { getByText, queryByText } = render( + + + + + + + , + ); + focus(getByText('Nested')); + focus(getByText('Inside')); + await waitFor(() => expect(queryByText('Nested')).toBeNull()); + expect(parentChange).not.toHaveBeenCalled(); + expect(childChange).toHaveBeenCalledTimes(1); + expect(getByText('Inside')).toHaveFocus(); + }); + + it('includes virtual-parent-linked portals without treating arbitrary portals as owned', async () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText } = render( + + {createPortal(, document.body)} + {createPortal(, document.body)} + , + ); + setVirtualParent(getByText('Linked portal'), getByText('Inside').closest('[popover]')!); + focus(getByText('Inside')); + focus(getByText('Linked portal')); + expect(onOpenChange).not.toHaveBeenCalled(); + focus(getByText('Unregistered portal')); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + (['open', 'closed'] as const).forEach(mode => { + it(`tracks focus inside a ${mode} shadow root and preserves outside destination focus`, async () => { + const { container, getByText } = render( + <> +
+ + , + ); + const root = container.firstElementChild!.attachShadow({ mode }); + const onOpenChange = jest.fn(); + render(createPortal(, root)); + const inside = root.querySelector('[popover] button') as HTMLButtonElement; + const trigger = root.querySelectorAll('button')[1]; + focus(inside); + focus(trigger); + expect(onOpenChange).not.toHaveBeenCalled(); + focus(getByText('Outside shadow')); + await waitFor(() => expect(root.querySelector('[popover]')).toBeNull()); + expect(getByText('Outside shadow')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it(`includes descendants in a nested ${mode} shadow root`, async () => { + const { getByText, queryByText } = render( + +
+ , + ); + const surface = getByText('Inside').closest('[popover]')!; + const root = surface.querySelector('[data-testid=shadow-host]')!.attachShadow({ mode }); + render(createPortal(, root)); + focus(getByText('Inside')); + focus(root.querySelector('button')!); + expect(getByText('Inside')).toBeInTheDocument(); + focus(getByText('After')); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('After')).toHaveFocus(); + }); + }); + }); + describe('close lifecycle', () => { const Example = ({ open, trapFocus = false }: { open: boolean; trapFocus?: boolean }) => ( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 25631e2dc882e..0443bccb05b93 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -8,6 +8,7 @@ import type { PositioningShorthand, PositioningReturn } from '../../hooks/usePos export type OpenPopoverEvents = | MouseEvent | TouchEvent + | FocusEvent | React.FocusEvent | React.KeyboardEvent | React.MouseEvent; @@ -59,6 +60,17 @@ export type PopoverProps = { */ mouseLeaveDelay?: number; + /** + * Close a non-modal popover when focus moves outside its trigger, surface, + * and their DOM, shadow, or Fluent virtual-parent descendants. + * Moving back to the trigger keeps the popover open. Opening while focus is + * elsewhere does not dismiss until focus enters and then leaves this boundary. + * Browser-window blur or an unknown focus destination alone does not close it. + * Ignored when `trapFocus` is true. + * @default false + */ + closeOnFocusOutside?: boolean; + /** * Positioning configuration. Accepts either a full `PositioningProps` * object or a shorthand string such as `'below'` / `'above-end'`. diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 582614f9329f2..3eee0045c1936 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -7,6 +7,7 @@ import { useId, useTimeout, useIsomorphicLayoutEffect, + elementContains, } from '@fluentui/react-utilities'; import { usePositioning, resolvePositioningShorthand } from '../../hooks'; import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types'; @@ -35,6 +36,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { mouseLeaveDelay = 500, withArrow = false, trapFocus = false, + closeOnFocusOutside = false, } = props; const [open, setOpenState] = useControllableState({ @@ -46,6 +48,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { const [shouldRenderSurface, setShouldRenderSurface] = React.useState(open); const focusWasInsideOnNativeClose = React.useRef(null); const openedSurface = React.useRef(null); + const boundaryFocus = React.useRef<{ surface: HTMLElement; inside: boolean } | null>(null); const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined); @@ -240,6 +243,62 @@ export const usePopover = (props: PopoverProps): PopoverState => { } }, [open, trapFocus, shouldRenderSurface, props.children, contentRef, finishSurfaceClose]); + useIsomorphicLayoutEffect(() => { + const surface = contentRef.current; + if (!open || trapFocus || !closeOnFocusOutside || !surface || typeof surface.showPopover !== 'function') { + boundaryFocus.current = null; + return; + } + + const trigger = triggerRef.current; + const isFocusInside = () => + [getActiveElement(surface), trigger && getActiveElement(trigger)].some( + element => !!element && (elementContains(surface, element) || elementContains(trigger, element)), + ); + const hasUnknownDestination = () => { + const activeElement = getActiveElement(surface); + return ( + !activeElement || + activeElement === surface.ownerDocument.body || + activeElement === surface.ownerDocument.documentElement + ); + }; + if (boundaryFocus.current?.surface !== surface) { + boundaryFocus.current = { surface, inside: false }; + } + const focus = boundaryFocus.current; + // Native opening can focus a descendant before this listener is attached. + // Preserve ownership across commits while the destination remains unknown. + if (isFocusInside()) { + focus.inside = true; + } else if (!hasUnknownDestination()) { + focus.inside = false; + } + const onFocusIn: EventListener = event => { + if (!surface.matches(':popover-open')) { + return; + } + if (isFocusInside()) { + focus.inside = true; + return; + } + if (hasUnknownDestination()) { + return; + } + if (focus.inside) { + focus.inside = false; + // Focus has reached its destination, so native close must not restore the trigger. + setOpen(event as FocusEvent, false); + } + }; + // Shadow-root listeners also see moves that are retargeted away at the document. + const roots = new Set([surface.ownerDocument, surface.getRootNode(), trigger?.getRootNode()]); + roots.forEach(root => root?.addEventListener('focusin', onFocusIn, true)); + return () => { + roots.forEach(root => root?.removeEventListener('focusin', onFocusIn, true)); + }; + }); + const children = React.Children.toArray(props.children) as React.ReactElement[]; if (process.env.NODE_ENV !== 'production') { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md index 21b1f44a9345b..34dfbdccb1003 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md @@ -1,3 +1,5 @@ A popover displays lightweight content anchored to a trigger element. Popovers are used for transient UI that appears on user interaction, such as additional information, forms, or menus. They support click, hover, and context-menu triggers, and can render with an arrow pointing to the trigger. + +Non-modal popovers follow the browser's normal Tab order and do not close on focus exit by default. Set `closeOnFocusOutside` to close when focus leaves the trigger, surface, and their DOM, shadow, or Fluent virtual-parent descendants. Returning to the trigger keeps the popover open; closing preserves focus on the destination control. A hover or programmatic opening while focus is elsewhere waits for focus to enter this boundary before focus-exit dismissal applies. Window blur or an unknown destination alone does not dismiss. Arbitrary portals without a Fluent virtual-parent link are outside this boundary. This option has no effect when `trapFocus` is enabled. From 4edd87592633d3432b6bae14b270de104b4f0f43 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 19 Oct 2026 17:22:45 +0200 Subject: [PATCH 02/12] fix(react-headless-components-preview): dismiss on focus exit by default Make non-modal focus-exit dismissal built-in accessibility behavior rather than a consumer opt-in. Remove the proposed public API additions and cover default TeachingPopover and InfoLabel behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44bec1f8-d9c2-4333-a285-7270b37fe5da --- ...-a73dd995-4801-4e2d-a38e-22b587c50e43.json | 4 +- .../library/docs/popover-spec.md | 63 +++++++++---------- .../library/etc/popover.api.md | 3 +- .../library/etc/teaching-popover.api.md | 1 - .../src/components/InfoLabel/InfoLabel.cy.tsx | 22 +++++++ .../src/components/Popover/Popover.cy.tsx | 28 ++++----- .../src/components/Popover/Popover.test.tsx | 17 ++--- .../src/components/Popover/Popover.types.ts | 12 ---- .../src/components/Popover/usePopover.ts | 7 +-- .../TeachingPopover/TeachingPopover.cy.tsx | 24 +++++++ .../stories/src/Popover/PopoverDescription.md | 2 +- 11 files changed, 106 insertions(+), 77 deletions(-) diff --git a/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json index 45043f8471c82..d9a4e951819dc 100644 --- a/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json +++ b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json @@ -1,6 +1,6 @@ { - "type": "minor", - "comment": "feat: add opt-in non-modal Popover focus-exit dismissal", + "type": "patch", + "comment": "fix: automatically dismiss non-modal Popover when focus leaves its logical boundary", "packageName": "@fluentui/react-headless-components-preview", "email": "paulmardling@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index 0c521368b65ce..54dc802107e8f 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -4,7 +4,7 @@ Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface elevates into the browser's **top layer** via the native HTML Popover API. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop. -Popover lets the browser manage light dismissal: a non-modal surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React. Hover-leave, programmatic close, and opt-in `closeOnFocusOutside` requests also flow through React before native closing. When `trapFocus` is enabled, the default `` surface opens modally with `role="dialog"`. +Popover lets the browser manage light dismissal: a non-modal surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React. Hover-leave, programmatic close, and automatic focus-exit dismissal also flow through React before native closing. When `trapFocus` is enabled, the default `` surface opens modally with `role="dialog"`. ## Composition @@ -24,18 +24,17 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with ### `Popover` -| Prop | Type | Default | Description | -| --------------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. | -| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. | -| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). | -| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). | -| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. | -| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. | -| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. | -| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). | -| `trapFocus` | `boolean` | `false` | Open the popover as a modal via `HTMLDialogElement.showModal()`. See [Focus management](#focus-management). | -| `closeOnFocusOutside` | `boolean` | `false` | Opt in to non-modal logical-boundary focus-exit dismissal. See [Opt-in focus-exit dismissal](#opt-in-focus-exit-dismissal). | +| Prop | Type | Default | Description | +| ----------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. | +| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. | +| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). | +| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). | +| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. | +| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. | +| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. | +| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). | +| `trapFocus` | `boolean` | `false` | Open the popover as a modal via `HTMLDialogElement.showModal()`. See [Focus management](#focus-management). | ### `PopoverTrigger` @@ -54,14 +53,14 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with ## States -| State | Trigger | Behaviour | ARIA | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Closed** | Initial, or after dismissal | Surface unmounted. Trigger has `aria-expanded="false"`, no `data-open`. | `aria-expanded="false"` on trigger. | -| **Open** | `open={true}` / click / keyboard activation / hover / right-click (depending on props) | Surface mounted and promoted into the top layer. `usePopover` calls `showModal()` when `trapFocus={true}` (modal trap), otherwise `showPopover()` (non-modal). Both methods are feature-detected. `data-placement` reflects the requested placement; `usePlacementObserver` overwrites it with the resolved placement. | `aria-expanded="true"`, `data-open` on trigger; `data-open` on surface. Surface role is explicitly `group` (default) or `dialog` (`trapFocus={true}`). | -| **Hover-held** | `openOnHover` and pointer inside trigger or surface | Popover stays open while pointer is inside either element; closes `mouseLeaveDelay` ms after it leaves both. | Same as Open. | -| **Context-pinned** | `openOnContext` + right-click | `onOpenChange(e, { type: 'contextmenu', open: true })` with the mouse event; `contextTarget` state stores `{ x, y }`. Click and keyboard activation on the trigger do nothing. | Same as Open. | -| **Dismissing** | Browser-driven: Escape, click-outside, popover-stack peer dismissal. Plus React-driven hover-leave (`openOnHover`) and programmatic close. | `toggle` event on the surface mirrors the browser's decision into React; `onOpenChange(e, { open: false, type })` fires with the originating event. | `aria-expanded` returns to `"false"` on trigger. | -| **Nested** | Popover rendered inside another Popover's surface | Nested popovers participate in the browser-managed `popover="auto"` stack. Escape, click-outside, and peer-dismissal use native HTML Popover semantics, and each surface mirrors its own `toggle` event back into React — no React-side Escape filtering. A nested `trapFocus` popover stacks via `showModal()` and suspends the outer trap until it closes (browser top-layer rules). | Each surface keeps its own role per its `trapFocus` setting. | +| State | Trigger | Behaviour | ARIA | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Closed** | Initial, or after dismissal | Surface unmounted. Trigger has `aria-expanded="false"`, no `data-open`. | `aria-expanded="false"` on trigger. | +| **Open** | `open={true}` / click / keyboard activation / hover / right-click (depending on props) | Surface mounted and promoted into the top layer. `usePopover` calls `showModal()` when `trapFocus={true}` (modal trap), otherwise `showPopover()` (non-modal). Both methods are feature-detected. `data-placement` reflects the requested placement; `usePlacementObserver` overwrites it with the resolved placement. | `aria-expanded="true"`, `data-open` on trigger; `data-open` on surface. Surface role is explicitly `group` (default) or `dialog` (`trapFocus={true}`). | +| **Hover-held** | `openOnHover` and pointer inside trigger or surface | Popover stays open while pointer is inside either element; closes `mouseLeaveDelay` ms after it leaves both. | Same as Open. | +| **Context-pinned** | `openOnContext` + right-click | `onOpenChange(e, { type: 'contextmenu', open: true })` with the mouse event; `contextTarget` state stores `{ x, y }`. Click and keyboard activation on the trigger do nothing. | Same as Open. | +| **Dismissing** | Browser-driven: Escape, click-outside, popover-stack peer dismissal. Plus React-driven focus exit, hover-leave (`openOnHover`), and programmatic close. | `toggle` event on the surface mirrors the browser's decision into React; focus-exit requests use `focusin`. `onOpenChange(e, { open: false, type })` fires with the originating event. | `aria-expanded` returns to `"false"` on trigger. | +| **Nested** | Popover rendered inside another Popover's surface | Nested popovers participate in the browser-managed `popover="auto"` stack. Escape, click-outside, and peer-dismissal use native HTML Popover semantics, and each surface mirrors its own `toggle` event back into React — no React-side Escape filtering. A nested `trapFocus` popover stacks via `showModal()` and suspends the outer trap until it closes (browser top-layer rules). | Each surface keeps its own role per its `trapFocus` setting. | ## Keyboard Navigation @@ -75,12 +74,12 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with ### Inside surface -| Key | Action | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Tab** | Default browser tab order; Tab may move focus out of the surface. With `trapFocus={true}`, the platform's modal `` semantics keep Tab inside the surface. | -| **Shift + Tab** | Default browser reverse tab order. | -| **Escape** | Dismiss the current popover. Filtered to the nearest enclosing surface, so Escape in a nested popover only closes that popover — not its ancestors. | -| **Enter / Space** | Default button activation inside the surface. | +| Key | Action | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Tab** | Default browser tab order. Leaving the trigger/surface logical boundary closes the non-modal popover and preserves destination focus. With `trapFocus={true}`, the platform's modal `` semantics keep Tab inside the surface. | +| **Shift + Tab** | Default browser reverse tab order; returning to the trigger keeps the popover open, and leaving the logical boundary closes it. | +| **Escape** | Dismiss the current popover. Filtered to the nearest enclosing surface, so Escape in a nested popover only closes that popover — not its ancestors. | +| **Enter / Space** | Default button activation inside the surface. | ## Events @@ -88,7 +87,7 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with | -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `onOpenChange` | `(e: SyntheticEvent \| DOMEvent, data: { event; type; open: boolean }) => void` | Whenever the popover wants to open or close (trigger click, trigger keyboard activation, hover, context menu, Escape, click-outside, scroll-outside, iframe focus move). | -There is no separate `onDismiss`. The `open: false` dispatches go through `onOpenChange` with a `type` that identifies the source (`'click'`, `'keydown'`, `'mouseleave'`, `'contextmenu'`, `'scroll'`, …). +There is no separate `onDismiss`. The `open: false` dispatches go through `onOpenChange` with a `type` that identifies the source (`'click'`, `'keydown'`, `'focusin'`, `'mouseleave'`, `'contextmenu'`, `'scroll'`, …). ## Accessibility @@ -156,9 +155,9 @@ Native opening, trapping, and closing use the platform APIs, with the close life - **`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. - **`trapFocus={true}`.** The browser opens the surface via `dialog.showModal()` and runs the modal `` 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. -#### Opt-in focus-exit dismissal +#### Automatic focus-exit dismissal -`closeOnFocusOutside` defaults to `false` and applies only when `trapFocus={false}`. When enabled, focus moving outside the logical boundary requests closing through `onOpenChange` with a native `focusin` event. Controlled consumers may accept or reject the request through `open`. Closing happens after destination focus is established, preserving focus on that destination rather than moving it back to the trigger. +When `trapFocus={false}`, focus moving outside the logical boundary automatically requests closing through `onOpenChange` with a native `focusin` event. This is built-in accessibility behavior: no additional prop or consumer focus handler is required. Controlled consumers continue to accept or reject open-state requests through the existing `open` prop. Closing happens after destination focus is established, preserving focus on that destination rather than moving it back to the trigger. The boundary includes the trigger, surface, and their DOM, shadow, and Fluent virtual-parent descendants. Moving between those members, including back to the trigger, keeps the popover open. Unregistered React portals are not automatically owned descendants. A trigger focused at opening immediately establishes boundary focus, including for text-only surfaces. Hover or programmatic opening with focus elsewhere waits for focus to enter the boundary before an exit can dismiss. @@ -213,7 +212,7 @@ Arrow positioning is **consumer-owned CSS** keyed off `[data-placement]`. The ho ## Open / dismiss model -The surface is rendered with `popover="auto"`, so the **browser owns light dismiss**. React owns opening and explicit close requests, including opt-in focus-exit dismissal. React-driven closing calls `hidePopover()` while the surface remains connected, then unmounts after native closing. Browser-driven dismissal is mirrored back into React via the surface's `toggle` event. +The surface is rendered with `popover="auto"`, so the **browser owns light dismiss**. React owns opening and explicit close requests, including automatic focus-exit dismissal. React-driven closing calls `hidePopover()` while the surface remains connected, then unmounts after native closing. Browser-driven dismissal is mirrored back into React via the surface's `toggle` event. ### Open paths (React-driven) @@ -331,7 +330,7 @@ Firefox and Safari are implementing CSS Anchor Positioning; most features work b ## Notes -- **Auto mode is the only non-modal mode**: the surface renders with `popover="auto"`, so the browser owns light dismiss and the popover-stack semantics. Opt-in focus-exit dismissal adds a React-driven close request without replacing native light dismiss. There is no manual-mode escape hatch for intercepting native dismissal or keeping multiple unrelated popovers open at once. +- **Auto mode is the only non-modal mode**: the surface renders with `popover="auto"`, so the browser owns light dismiss and the popover-stack semantics. Automatic focus-exit dismissal adds a React-driven close request without replacing native light dismiss. There is no manual-mode escape hatch for intercepting native dismissal or keeping multiple unrelated popovers open at once. - **Nested popovers**: nesting is JSX-nesting. The browser's popover-stack treats the inner trigger's DOM descendancy of the outer surface as the ancestor signal — Escape closes only the topmost popover, click-outside dismisses the chain. - **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface. Closing waits `mouseLeaveDelay` ms and follows the React-driven native-close lifecycle. - **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger. diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md index eca846d271c7a..4a4241a26ffc7 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -25,7 +25,7 @@ export type OnOpenChangeData = EventData }; // @public -export type OpenPopoverEvents = MouseEvent | TouchEvent | FocusEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent; +export type OpenPopoverEvents = MouseEvent | TouchEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent; // @public export const Popover: { @@ -50,7 +50,6 @@ export type PopoverProps = { openOnHover?: boolean; openOnContext?: boolean; mouseLeaveDelay?: number; - closeOnFocusOutside?: boolean; positioning?: PositioningShorthand; withArrow?: boolean; id?: string; diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md index 784cab5224144..36364e77a9828 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -233,7 +233,6 @@ export type TeachingPopoverProps = { openOnHover?: boolean; openOnContext?: boolean; mouseLeaveDelay?: number; - closeOnFocusOutside?: boolean; positioning?: PositioningShorthand; withArrow?: boolean; id?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index 064f7935c7c20..eabb9ec4bbc7a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx @@ -14,6 +14,28 @@ describe('InfoLabel - close on tab-out', () => { return cy.get('button.info-button').focus().realPress('{enter}'); }; + it('preserves destination focus and does not duplicate close notifications', () => { + mount( + <> + Inside} + infoButton={{ + className: 'info-button', + popover: { onOpenChange: cy.spy().as('change') }, + }} + /> + + , + ); + openInfoButton().get(surfaceSelector).should('exist'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress('Tab'); + cy.get(surfaceSelector).should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.callCount', 0); + }); + it('no focusable elements', () => { mount( <> diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index 94c80ca5fcbd6..f1d2c966b85b4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -17,16 +17,14 @@ const mount = (element: JSXElement) => { const popoverTriggerSelector = '[aria-expanded]'; const popoverContentSelector = '[role="group"]'; -describe('opt-in focus-exit dismissal', () => { +describe('automatic focus-exit dismissal', () => { const Example = ({ textOnly = false, mode = 'uncontrolled', - closeOnFocusOutside = true, onOpenChange, }: { textOnly?: boolean; mode?: 'uncontrolled' | 'accept' | 'reject'; - closeOnFocusOutside?: boolean; onOpenChange?: PopoverProps['onOpenChange']; }) => { const [open, setOpen] = React.useState(false); @@ -35,7 +33,6 @@ describe('opt-in focus-exit dismissal', () => { { onOpenChange?.(event, data); if (mode !== 'reject' || data.open) { @@ -101,21 +98,22 @@ describe('opt-in focus-exit dismissal', () => { cy.get('[data-testid=surface]').should('not.exist'); }); - it('does not change Tab-through behavior without opt-in', () => { - mount(); + it('dismisses by default on a programmatic focus move without an outside click', () => { + mount(); cy.get('[data-testid=trigger]').focus().realPress('Enter'); cy.get('[data-testid=surface]').should('be.visible'); cy.get('@change').invoke('resetHistory'); - cy.get('[data-testid=inside]').focus().realPress('Tab'); + cy.get('[data-testid=inside]').focus(); + cy.get('[data-testid=after]').focus(); cy.get('[data-testid=after]').should('have.focus'); - cy.get('[data-testid=surface]').should('be.visible'); - cy.get('@change').should('not.have.been.called'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('@change').should('have.been.calledOnce'); }); it('seeds autofocus before listeners attach and preserves the Tab destination', () => { mount( <> - + - + @@ -159,7 +157,7 @@ describe('opt-in focus-exit dismissal', () => { cy.get('@change').should('have.been.calledOnce'); }); - it('keeps Escape restoration and click-outside dismissal working with opt-in', () => { + it('keeps Escape restoration and click-outside dismissal working', () => { mount(); cy.get('[data-testid=trigger]').focus().realPress('Enter'); cy.get('[data-testid=surface]').should('be.visible'); @@ -179,12 +177,12 @@ describe('opt-in focus-exit dismissal', () => { it('does not close a parent when focus enters a nested surface or returns to its trigger', () => { mount( - + - + @@ -239,7 +237,7 @@ describe('opt-in focus-exit dismissal', () => { const surface = React.useRef(null); return ( <> - + diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx index 57dd6331e6027..1c9e93bbae296 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx @@ -10,12 +10,12 @@ import { usePopover } from './usePopover'; import type { PopoverProps } from './Popover.types'; describe('Popover', () => { - describe('closeOnFocusOutside', () => { + describe('automatic focus-exit dismissal', () => { const focus = (element: HTMLElement) => act(() => element.focus()); const Example = ({ textOnly = false, ...props }: Partial & { textOnly?: boolean }) => ( <> - + @@ -28,13 +28,14 @@ describe('Popover', () => { ); - it('preserves default Tab-through behavior without opting in', () => { + it('closes by default when focus moves to the next page control', async () => { const onOpenChange = jest.fn(); - const { getByText } = render(); + const { getByText, queryByText } = render(); focus(getByText('Inside')); focus(getByText('After')); - expect(getByText('Inside')).toBeInTheDocument(); - expect(onOpenChange).not.toHaveBeenCalled(); + await waitFor(() => expect(queryByText('Inside')).toBeNull()); + expect(getByText('After')).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); }); it('closes once on Tab out and preserves destination focus', async () => { @@ -77,7 +78,7 @@ describe('Popover', () => { it('seeds focus moved by native opening before the listener is attached', () => { const onOpenChange = jest.fn(); const { getByText } = render( - + @@ -179,7 +180,7 @@ describe('Popover', () => { const childChange = jest.fn(); const { getByText, queryByText } = render( - + diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 0443bccb05b93..25631e2dc882e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -8,7 +8,6 @@ import type { PositioningShorthand, PositioningReturn } from '../../hooks/usePos export type OpenPopoverEvents = | MouseEvent | TouchEvent - | FocusEvent | React.FocusEvent | React.KeyboardEvent | React.MouseEvent; @@ -60,17 +59,6 @@ export type PopoverProps = { */ mouseLeaveDelay?: number; - /** - * Close a non-modal popover when focus moves outside its trigger, surface, - * and their DOM, shadow, or Fluent virtual-parent descendants. - * Moving back to the trigger keeps the popover open. Opening while focus is - * elsewhere does not dismiss until focus enters and then leaves this boundary. - * Browser-window blur or an unknown focus destination alone does not close it. - * Ignored when `trapFocus` is true. - * @default false - */ - closeOnFocusOutside?: boolean; - /** * Positioning configuration. Accepts either a full `PositioningProps` * object or a shorthand string such as `'below'` / `'above-end'`. diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 3eee0045c1936..14f9f404b2265 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -36,7 +36,6 @@ export const usePopover = (props: PopoverProps): PopoverState => { mouseLeaveDelay = 500, withArrow = false, trapFocus = false, - closeOnFocusOutside = false, } = props; const [open, setOpenState] = useControllableState({ @@ -52,13 +51,13 @@ export const usePopover = (props: PopoverProps): PopoverState => { const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined); - const onOpenChange = useEventCallback((e: OpenPopoverEvents, shouldOpen: boolean) => { + const onOpenChange = useEventCallback((e: OpenPopoverEvents | FocusEvent, shouldOpen: boolean) => { props.onOpenChange?.(e, { event: e, type: e.type, open: shouldOpen }); }); const [setOpenTimeout, clearOpenTimeout] = useTimeout(); - const setOpen = useEventCallback((e: OpenPopoverEvents, shouldOpen: boolean) => { + const setOpen = useEventCallback((e: OpenPopoverEvents | FocusEvent, shouldOpen: boolean) => { clearOpenTimeout(); if (shouldOpen && e.type === 'contextmenu') { @@ -245,7 +244,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { useIsomorphicLayoutEffect(() => { const surface = contentRef.current; - if (!open || trapFocus || !closeOnFocusOutside || !surface || typeof surface.showPopover !== 'function') { + if (!open || trapFocus || !surface || typeof surface.showPopover !== 'function') { boundaryFocus.current = null; return; } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx index 6408bef280f57..af6cb2dc85507 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx @@ -22,6 +22,30 @@ const triggerSelector = '[aria-expanded]'; const surfaceSelector = '[role="group"]'; describe('TeachingPopover', () => { + it('automatically closes on Tab-out without stealing destination focus', () => { + mount( + <> + + + + + + + + + + , + ); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress('Tab'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + cy.get('@change').its('firstCall.args.1').should('include', { open: false, type: 'focusin' }); + }); + (['uncontrolled', 'controlled'] as const).forEach(scenario => { const UncontrolledExample = () => ( diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md index 34dfbdccb1003..d51c9a2150248 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md @@ -2,4 +2,4 @@ A popover displays lightweight content anchored to a trigger element. Popovers are used for transient UI that appears on user interaction, such as additional information, forms, or menus. They support click, hover, and context-menu triggers, and can render with an arrow pointing to the trigger. -Non-modal popovers follow the browser's normal Tab order and do not close on focus exit by default. Set `closeOnFocusOutside` to close when focus leaves the trigger, surface, and their DOM, shadow, or Fluent virtual-parent descendants. Returning to the trigger keeps the popover open; closing preserves focus on the destination control. A hover or programmatic opening while focus is elsewhere waits for focus to enter this boundary before focus-exit dismissal applies. Window blur or an unknown destination alone does not dismiss. Arbitrary portals without a Fluent virtual-parent link are outside this boundary. This option has no effect when `trapFocus` is enabled. +Non-modal popovers follow the browser's normal Tab order and automatically close when focus leaves the trigger, surface, and their DOM, shadow, or Fluent virtual-parent descendants. Returning to the trigger keeps the popover open; closing preserves focus on the destination control. No additional prop or consumer focus handler is required. A hover or programmatic opening while focus is elsewhere waits for focus to enter this boundary before focus-exit dismissal applies. Window blur or an unknown destination alone does not dismiss. Arbitrary portals without a Fluent virtual-parent link are outside this boundary. Modal popovers (`trapFocus`) retain native dialog focus trapping. From fe52506cf50a19b1aad67f355fd5f069cc59c96f Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 19 Oct 2026 18:04:29 +0200 Subject: [PATCH 03/12] fix(react-headless-components-preview): harden focus-boundary observation Preserve logical React portal focus through external shadow roots, retain native listeners across commits, and detect confirmed iframe focus exits without treating window blur alone as dismissal. Add unit and real-browser regressions while keeping the public API unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44bec1f8-d9c2-4333-a285-7270b37fe5da --- .../library/docs/popover-spec.md | 4 + .../src/components/Popover/Popover.cy.tsx | 162 +++++++++++++++++ .../src/components/Popover/Popover.test.tsx | 103 ++++++++++- .../src/components/Popover/usePopover.ts | 165 +++++++++++++----- 4 files changed, 380 insertions(+), 54 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index 54dc802107e8f..1946285faeb42 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -163,6 +163,10 @@ The boundary includes the trigger, surface, and their DOM, shadow, and Fluent vi Window blur and an unknown destination (`relatedTarget={null}`) alone do not request dismissal. A subsequent confirmed focus move outside can dismiss; unrelated external focus movement on a popover that has never owned focus cannot. +Tabbing into an outside iframe is a confirmed destination and dismisses the popover while preserving focus inside the frame. An iframe within the logical boundary stays owned. Native focus observation remains attached across ordinary commits so descendant layout-effect focus moves are not lost. + +Owned React/Fluent portals in external open or closed shadow roots retain their logical focus destination through React focus capture. Virtual ownership is still checked; an unregistered React portal is outside the boundary. Manually linked non-React content hidden inside an inaccessible external closed shadow root is not observable from outside that root and is not covered by this portal support. + #### Specifying initial focus 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. diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index f1d2c966b85b4..29468e614002d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -205,6 +205,168 @@ describe('automatic focus-exit dismissal', () => { cy.get('@parentChange').should('not.have.been.called'); }); + (['open', 'closed'] as const).forEach(mode => { + ([true, false] as const).forEach(linked => { + it(`${linked ? 'keeps' : 'dismisses for'} a ${ + linked ? 'linked' : 'non-owned' + } React portal in an external ${mode} shadow root`, () => { + cy.document().then(doc => { + const host = doc.createElement('div'); + doc.body.append(host); + const root = host.attachShadow({ mode }); + const PortalExample = () => { + const surface = React.useRef(null); + const portal = React.useRef(null); + useIsomorphicLayoutEffect(() => { + if (linked && surface.current && portal.current) { + setVirtualParent(portal.current, surface.current); + } + }); + return ( + <> + + + + {createPortal(, root)} + + + + + ); + }; + mount(); + cy.get('[data-testid=inside]').focus(); + cy.then(() => cy.wrap(root.querySelector('button')!).focus()); + cy.get('[data-testid=surface]').should(linked ? 'be.visible' : 'not.exist'); + if (linked) { + cy.then(() => expect(root.activeElement).to.equal(root.querySelector('button'))); + cy.get('@change').should('not.have.been.called'); + cy.get('[data-testid=after]').focus(); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + } + cy.get('@change').should('have.been.calledOnce'); + cy.then(() => host.remove()); + }); + }); + }); + }); + + it('observes descendant layout-effect focus movement without a commit-time listener gap', () => { + const outside = React.createRef(); + const MoveFocus = ({ move }: { move: boolean }) => { + useIsomorphicLayoutEffect(() => { + if (move) { + outside.current?.focus(); + } + }, [move]); + return null; + }; + const UpdatingExample = () => { + const [move, setMove] = React.useState(false); + return ( + <> + + + + + + + + + ); + }; + mount(); + cy.get('[data-testid=inside]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + }); + + (['uncontrolled', 'accept', 'reject'] as const).forEach(mode => { + it(`handles real Tab into an outside iframe (${mode})`, () => { + const FrameExample = () => { + const [open, setOpen] = React.useState(true); + return ( + <> + { + change(event, data); + if (mode === 'accept') { + setOpen(data.open); + } + }} + > + + + + + ' + } + /> + + ); + }; + mount(); + cy.get('[data-testid=outside-frame]').should(frames => { + const child = frames[0].contentDocument; + expect(child?.querySelector('#nested')?.contentDocument?.querySelector('#deep')).to.exist; + }); + cy.get('[data-testid=outside-frame]').then(frames => { + const child = frames[0].contentDocument!; + const grandchild = child.querySelector('#nested')!.contentDocument!; + const interval = cy.spy(child.defaultView!, 'setInterval').as('childInterval'); + const clearInterval = cy.spy(child.defaultView!, 'clearInterval').as('childClearInterval'); + const add = cy.spy(grandchild, 'addEventListener').as('grandchildAdd'); + const remove = cy.spy(grandchild, 'removeEventListener').as('grandchildRemove'); + cy.get('[data-testid=inside]').focus(); + cy.wrap(child.querySelector('#flat')!).focus(); + cy.get('@change').should('have.been.calledOnce'); + cy.get('[data-testid=inside]').focus(); + cy.then(() => { + unmountOnClose = true; + }); + cy.wrap(grandchild.querySelector('#deep')!).focus(); + cy.get('[data-testid=surface]').should('not.exist'); + cy.get('@change').should('have.been.calledTwice'); + cy.then(() => { + const polls = interval.getCalls().filter(call => call.args[1] === 100); + expect(polls).not.to.be.empty; + polls.forEach(call => expect(clearInterval).to.have.been.calledWith(call.returnValue)); + const listeners = add.getCalls().filter(call => call.args[0] === 'fuiframefocus'); + expect(listeners).not.to.be.empty; + listeners.forEach(call => expect(remove).to.have.been.calledWith('fuiframefocus', call.args[1], true)); + }); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 4f7574e87d4bd..1ce7515c2999d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -15,7 +15,7 @@ import type { InternalPopoverContextValue } from './popoverContext'; type ToggleEvent = Event & { newState?: 'open' | 'closed' }; type InternalPopoverState = PopoverState & Pick; -type FocusObservation = { event: Event; target: Node | null }; +type FocusObservation = { event: Event; target: Node | null; generation: number }; const isDialogElement = (el: Element | null): el is HTMLDialogElement => !!el && typeof (el as HTMLDialogElement).showModal === 'function'; @@ -57,6 +57,9 @@ export const usePopover = (props: PopoverProps): PopoverState => { const focusObservations = React.useRef([]); const focusReconciliationQueued = React.useRef(false); const framePolls = React.useRef(new Map()); + const observationLifecycle = React.useRef({ mounted: true, generation: 0 }); + const eventGenerations = React.useRef(new WeakMap()); + const retiredFocusDocuments = React.useRef(new WeakSet()); const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined); @@ -225,10 +228,10 @@ export const usePopover = (props: PopoverProps): PopoverState => { if (!shouldRenderSurface) { setShouldRenderSurface(true); } - focusWasInsideOnNativeClose.current = null; if (pendingNativeClose.current === surface && !surface.matches(':popover-open')) { return; } + focusWasInsideOnNativeClose.current = null; pendingNativeClose.current = null; if (trapFocus) { if (isDialogElement(surface) && !surface.open) { @@ -292,13 +295,46 @@ export const usePopover = (props: PopoverProps): PopoverState => { return active; }; + const getActiveFrameChain = (doc: Document): Array<{ doc: Document; active: Element }> => { + const chain: Array<{ doc: Document; active: Element }> = []; + const visited = new Set(); + let current = doc; + while (!visited.has(current)) { + visited.add(current); + const active = getDeepActiveElement(current); + if (!active) { + break; + } + chain.push({ doc: current, active }); + const childDocument = active.nodeName === 'IFRAME' ? (active as HTMLIFrameElement).contentDocument : null; + if (!childDocument || !childDocument.hasFocus()) { + break; + } + current = childDocument; + } + return chain; + }; + const reconcileFocus = useEventCallback(() => { - const observations = focusObservations.current.splice(0); + const generation = observationLifecycle.current.generation; + const observations = focusObservations.current.splice(0).filter(item => item.generation === generation); const surface = contentRef.current; - if (!open || trapFocus || !surface || typeof surface.showPopover !== 'function') { + if ( + !observationLifecycle.current.mounted || + !open || + trapFocus || + !surface || + typeof surface.showPopover !== 'function' + ) { return; } - if (!surface.matches(':popover-open')) { + const isCurrentSurface = () => + observationLifecycle.current.mounted && + observationLifecycle.current.generation === generation && + contentRef.current === surface && + surface.isConnected && + surface.matches(':popover-open'); + if (!isCurrentSurface()) { return; } if (observations.some(observation => isFocusInside(observation.target))) { @@ -311,21 +347,29 @@ export const usePopover = (props: PopoverProps): PopoverState => { documents.add(root as Document); } }); - const focusedDocument = + const focusedRootDocument = [...observations] .reverse() .map(observation => observation.target?.ownerDocument) .find(doc => doc?.hasFocus()) ?? [...documents].find(doc => doc?.hasFocus()); - documents.forEach(doc => { - if (!doc) { + const activeChain = focusedRootDocument ? getActiveFrameChain(focusedRootDocument) : []; + activeChain.forEach(item => documents.add(item.doc)); + for (const doc of documents) { + if (!isCurrentSurface()) { return; } + if (!doc) { + continue; + } const win = doc.defaultView; const active = getDeepActiveElement(doc); const isFrame = active?.nodeName === 'IFRAME' || active?.nodeName === 'WEBVIEW'; if (active?.nodeName === 'IFRAME') { const frameDocument = (active as HTMLIFrameElement).contentDocument; if (frameDocument) { + if (!isCurrentSurface()) { + return; + } observeRoot(frameDocument); } } @@ -334,33 +378,50 @@ export const usePopover = (props: PopoverProps): PopoverState => { framePolls.current.set( doc, win.setInterval(() => { + const currentWindow = doc.defaultView; const destination = getDeepActiveElement(doc); - if (!doc.hasFocus() || !destination) { - win.clearInterval(framePolls.current.get(doc)); + if ( + !observationLifecycle.current.mounted || + observationLifecycle.current.generation !== generation || + !currentWindow || + !doc.hasFocus() || + !destination + ) { + currentWindow?.clearInterval(framePolls.current.get(doc)); framePolls.current.delete(doc); return; } - destination.dispatchEvent(new win.CustomEvent('fuiframefocus', { bubbles: true, composed: true })); + destination.dispatchEvent( + new currentWindow.CustomEvent('fuiframefocus', { bubbles: true, composed: true }), + ); }, 100), ); } else if (win && (!isFrame || !doc.hasFocus())) { win.clearInterval(framePolls.current.get(doc)); framePolls.current.delete(doc); } - if (!active || active === doc.body || active === doc.documentElement || doc !== focusedDocument) { - return; - } - if (isFocusInside(active)) { - focusWasInside.current = true; - return; - } - const observation = - [...observations].reverse().find(item => item.target === active) ?? observations[observations.length - 1]; - if (focusWasInside.current && observation) { - focusWasInside.current = false; - setOpen(observation.event, false); - } - }); + } + if (!isCurrentSurface()) { + return; + } + if (activeChain.some(item => isFocusInside(item.active))) { + focusWasInside.current = true; + return; + } + const destination = [...activeChain] + .reverse() + .find(item => item.active !== item.doc.body && item.active !== item.doc.documentElement); + if (!destination) { + return; + } + const observation = + [...observations].reverse().find(item => item.target === destination.active) ?? + observations[observations.length - 1]; + if (focusWasInside.current && observation) { + focusWasInside.current = false; + // Consumer code may synchronously close or unmount; allocate nothing after this callback. + setOpen(observation.event, false); + } }); function scheduleFocusReconciliation() { @@ -374,7 +435,16 @@ export const usePopover = (props: PopoverProps): PopoverState => { } function observeFocus(event: Event, target: Node | null) { - focusObservations.current.push({ event, target }); + const lifecycle = observationLifecycle.current; + if (!lifecycle.mounted || (target && !target.isConnected)) { + return; + } + const previousGeneration = eventGenerations.current.get(event); + if (previousGeneration !== undefined && previousGeneration !== lifecycle.generation) { + return; + } + eventGenerations.current.set(event, lifecycle.generation); + focusObservations.current.push({ event, target, generation: lifecycle.generation }); if (target) { observeRootChain(target); } @@ -401,34 +471,67 @@ export const usePopover = (props: PopoverProps): PopoverState => { } function observeRoot(root: Node) { - if (focusObservers.current.has(root)) { + const ownerDocument = root.nodeType === 9 ? (root as Document) : root.ownerDocument; + if ( + !observationLifecycle.current.mounted || + focusObservers.current.has(root) || + !ownerDocument?.defaultView || + (retiredFocusDocuments.current.has(ownerDocument) && !ownerDocument.hasFocus()) + ) { return; } + retiredFocusDocuments.current.delete(ownerDocument); const onFocus = (event: Event) => observeFocus(event, (event.composedPath()[0] ?? event.target) as Node | null); const doc = root.nodeType === 9 ? (root as Document) : undefined; const win = doc?.defaultView; let timeout: number | undefined; + let disposed = false; const onWindowBlur = (event: Event) => { - if (win) { - win.clearTimeout(timeout); - timeout = win.setTimeout(() => observeFocus(event, null), 0); + const currentWindow = doc?.defaultView; + const generation = observationLifecycle.current.generation; + if (!disposed && currentWindow) { + currentWindow.clearTimeout(timeout); + timeout = currentWindow.setTimeout(() => { + if (!disposed && observationLifecycle.current.generation === generation) { + observeFocus(event, null); + } + }, 0); } }; const onWindowFocus = (event: Event) => observeFocus(event, null); + const onPageHide = (event: PageTransitionEvent) => { + if (doc && !event.persisted) { + retiredFocusDocuments.current.add(doc); + focusObservers.current.forEach((dispose, observedRoot) => { + if (observedRoot === doc || observedRoot.ownerDocument === doc) { + dispose(); + } + }); + } + }; root.addEventListener('focusin', onFocus, true); root.addEventListener('fuiframefocus', onFocus, true); win?.addEventListener('blur', onWindowBlur); win?.addEventListener('focus', onWindowFocus); + win?.addEventListener('pagehide', onPageHide); focusObservers.current.set(root, () => { + if (disposed) { + return; + } + disposed = true; root.removeEventListener('focusin', onFocus, true); root.removeEventListener('fuiframefocus', onFocus, true); - win?.removeEventListener('blur', onWindowBlur); - win?.removeEventListener('focus', onWindowFocus); - win?.clearTimeout(timeout); - if (doc && win) { - win.clearInterval(framePolls.current.get(doc)); + // A captured WindowProxy can become cross-origin after its document navigates. + const currentWindow = doc?.defaultView; + currentWindow?.removeEventListener('blur', onWindowBlur); + currentWindow?.removeEventListener('focus', onWindowFocus); + currentWindow?.removeEventListener('pagehide', onPageHide); + currentWindow?.clearTimeout(timeout); + if (doc) { + currentWindow?.clearInterval(framePolls.current.get(doc)); framePolls.current.delete(doc); } + focusObservers.current.delete(root); }); } @@ -439,7 +542,9 @@ export const usePopover = (props: PopoverProps): PopoverState => { }; useIsomorphicLayoutEffect(() => { + observationLifecycle.current.mounted = true; if (!open || trapFocus) { + observationLifecycle.current.generation++; focusWasInside.current = false; focusObservations.current = []; focusObservers.current.forEach(dispose => dispose()); @@ -464,7 +569,10 @@ export const usePopover = (props: PopoverProps): PopoverState => { }); useIsomorphicLayoutEffect(() => { const observers = focusObservers.current; + const lifecycle = observationLifecycle.current; return () => { + lifecycle.mounted = false; + lifecycle.generation++; observers.forEach(dispose => dispose()); observers.clear(); }; From 92e88b4e9e39e6a6e0a9de7136e1be7c24fd25e7 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Wed, 21 Oct 2026 15:12:30 +0200 Subject: [PATCH 07/12] fix(react-headless-components-preview): address Popover CI failures Normalize the test-only Portal dependency, close native state before releasing the surface in legacy React roots, and compact private observation state with one scoped frame sampler. Preserve the existing public API and 10% bundle budget. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44bec1f8-d9c2-4333-a285-7270b37fe5da --- .../library/docs/popover-spec.md | 2 +- .../library/package.json | 2 +- .../components/Popover/PopoverFocus.test.tsx | 20 + .../Popover/PopoverLifecycle.cy.tsx | 15 +- .../src/components/Popover/usePopover.ts | 671 +++++++++--------- yarn.lock | 2 +- 6 files changed, 351 insertions(+), 361 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index 8fccf34555f0b..5e85fbff74135 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -163,7 +163,7 @@ The boundary includes the trigger, surface, and their DOM, shadow, and Fluent vi Window blur and an unknown destination (`relatedTarget={null}`) alone do not request dismissal. A subsequent confirmed focus move outside can dismiss; unrelated external focus movement on a popover that has never owned focus cannot. -Tabbing into an outside iframe is a confirmed destination and dismisses the popover while preserving focus inside the frame. An iframe within the logical boundary stays owned. Accessible same-origin frame documents are observed directly. While a known frame holds focus, a scoped 100 ms fallback samples transitions that do not produce parent focus events, including cross-origin frame-to-frame navigation. A complete inside/outside round trip between samples in an inaccessible frame can be missed; instantaneous or universal cross-origin detection is not promised. Sampling stops when frame focus ends or the popover closes/unmounts. Observed-frame callbacks use the existing `fuiframefocus` event semantics. +Tabbing into an outside iframe is a confirmed destination and dismisses the popover while preserving focus inside the frame. An iframe within the logical boundary stays owned. Accessible same-origin frame documents are observed directly. While a known frame holds focus, one scoped 100 ms sampler per Popover covers transitions that do not produce parent focus events, including cross-origin frame-to-frame navigation. A complete inside/outside round trip between samples in an inaccessible frame can be missed; instantaneous or universal cross-origin detection is not promised. Sampling stops when frame focus ends, the surface is replaced, or the popover closes/unmounts. Observed-frame callbacks use the existing `fuiframefocus` event semantics. Accessible active frame chains are resolved to their focused control, so a virtually owned portal inside an otherwise external iframe remains within the boundary. Navigation retires discarded documents' subscriptions without accessing their former windows across origins. Cached documents retain observation while frozen, so Back/Forward restoration does not require a React commit to resume dismissal. Cleanup also covers synchronous unmounts from `onOpenChange`; stale focus captures cannot recreate subscriptions or sampling afterward. 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 655f0026538bd..db1138075eac0 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -662,7 +662,7 @@ ] }, "devDependencies": { - "@fluentui/react-portal": "^9.8.17", + "@fluentui/react-portal": "*", "@fluentui/scripts-cypress": "*", "@oddbird/popover-polyfill": "^0.6.1" }, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverFocus.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverFocus.test.tsx index 2bf80e929161e..8d07065780c4b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverFocus.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverFocus.test.tsx @@ -62,6 +62,26 @@ describe('Popover frame observation lifecycle', () => { unmount(); }); + it('shares one sampler across nested frame documents', async () => { + const interval = jest.spyOn(window, 'setInterval'); + const clear = jest.spyOn(window, 'clearInterval'); + const { getByTitle, getByText, unmount } = render(); + const frame = getByTitle('Inside frame') as HTMLIFrameElement; + const child = frame.contentDocument!; + const nested = child.createElement('iframe'); + child.body.append(nested); + const button = nested.contentDocument!.createElement('button'); + nested.contentDocument!.body.append(button); + const childInterval = jest.spyOn(child.defaultView!, 'setInterval'); + await settle(() => frame.focus()); + await settle(() => button.focus()); + expect(interval).toHaveBeenCalledTimes(1); + expect(childInterval).not.toHaveBeenCalled(); + await settle(() => getByText('Outside').focus()); + expect(clear).toHaveBeenCalledWith(interval.mock.results[0].value); + unmount(); + }); + it('cleans up pending frame checks and intervals on close and unmount', async () => { const interval = jest.spyOn(window, 'setInterval'); const clear = jest.spyOn(window, 'clearInterval'); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverLifecycle.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverLifecycle.cy.tsx index 2baacebb5a6c7..3fcce20859c0f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverLifecycle.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverLifecycle.cy.tsx @@ -191,8 +191,10 @@ describe('Popover document and close lifecycle', () => { cy.get('[data-testid=outside-frame]').then(frames => { const child = frames[0].contentDocument!; const grandchild = child.querySelector('#nested')!.contentDocument!; - const interval = cy.spy(child.defaultView!, 'setInterval').as('childInterval'); - const clearInterval = cy.spy(child.defaultView!, 'clearInterval').as('childClearInterval'); + const intervals = [frames[0].ownerDocument.defaultView!, child.defaultView!].map(win => ({ + interval: cy.spy(win, 'setInterval'), + clearInterval: cy.spy(win, 'clearInterval'), + })); const add = cy.spy(grandchild, 'addEventListener').as('grandchildAdd'); const remove = cy.spy(grandchild, 'removeEventListener').as('grandchildRemove'); cy.get('[data-testid=inside]').focus(); @@ -206,9 +208,14 @@ describe('Popover document and close lifecycle', () => { cy.get('[data-testid=surface]').should('not.exist'); cy.get('@change').should('have.been.calledTwice'); cy.then(() => { - const polls = interval.getCalls().filter(call => call.args[1] === 100); + const polls = intervals.flatMap(({ interval, clearInterval }) => + interval + .getCalls() + .filter(call => call.args[1] === 100) + .map(call => ({ call, clearInterval })), + ); expect(polls).not.to.be.empty; - polls.forEach(call => expect(clearInterval).to.have.been.calledWith(call.returnValue)); + polls.forEach(({ call, clearInterval }) => expect(clearInterval).to.have.been.calledWith(call.returnValue)); const listeners = add.getCalls().filter(call => call.args[0] === 'fuiframefocus'); expect(listeners).not.to.be.empty; listeners.forEach(call => expect(remove).to.have.been.calledWith('fuiframefocus', call.args[1], true)); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 1ce7515c2999d..e6ebafafd4799 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -15,18 +15,296 @@ import type { InternalPopoverContextValue } from './popoverContext'; type ToggleEvent = Event & { newState?: 'open' | 'closed' }; type InternalPopoverState = PopoverState & Pick; -type FocusObservation = { event: Event; target: Node | null; generation: number }; const isDialogElement = (el: Element | null): el is HTMLDialogElement => !!el && typeof (el as HTMLDialogElement).showModal === 'function'; const getActiveElement = (surface: HTMLElement): Element | null => { - const root = surface.getRootNode(); - const ShadowRootConstructor = surface.ownerDocument.defaultView?.ShadowRoot; + const root = surface.getRootNode() as Document | ShadowRoot; // Keep focus retargeted to this root: Node.contains does not cross shadow boundaries. - return ShadowRootConstructor && root instanceof ShadowRootConstructor - ? root.activeElement ?? surface.ownerDocument.activeElement - : surface.ownerDocument.activeElement; + return root.activeElement ?? surface.ownerDocument.activeElement; +}; +const isPopoverOpen = (surface: HTMLElement) => surface.matches(':popover-open'); +const hasSurfaceFocus = (surface: HTMLElement) => surface.contains(getActiveElement(surface)); + +const createFocusObserver = () => { + let content: HTMLElement | null = null; + let trigger: HTMLElement | null = null; + let setOpen: (event: Event, open: boolean) => void; + // Null rejects stale captures after disposal; false still permits pre-layout capture. + let enabled: boolean | null = false; + let focusWasInside = false; + const focusObservers = new Map void>(); + const knownShadowRoots = new Map(); + let focusObservations: Map | undefined; + let framePoll: { doc: Document; id: number } | undefined; + let generation = 0; + const eventGenerations = new WeakMap(); + const retiredFocusDocuments = new WeakSet(); + + const isFocusInside = (target: Node | null) => { + let current = target; + while (current) { + if (elementContains(content, current) || elementContains(trigger, current)) { + return true; + } + current = current.ownerDocument?.defaultView?.frameElement ?? null; + } + return false; + }; + const getDeepActiveElement = (doc: Document): Element | null => { + let active = doc.activeElement; + while (active) { + const root = active.shadowRoot ?? knownShadowRoots.get(active); + if (!root?.activeElement) { + break; + } + active = root.activeElement; + } + return active; + }; + + const getDocuments = () => [...focusObservers.keys()].filter((root): root is Document => root.nodeType === 9); + const getFocusedFrame = () => { + for (const doc of getDocuments()) { + const active = doc.hasFocus() ? getDeepActiveElement(doc) : null; + if (active?.nodeName === 'IFRAME' || active?.nodeName === 'WEBVIEW') { + return active; + } + } + }; + const stopFramePoll = () => { + framePoll?.doc.defaultView?.clearInterval(framePoll.id); + framePoll = undefined; + }; + + const reconcileFocus = () => { + const epoch = generation; + const observations = focusObservations; + focusObservations = undefined; + const surface = content; + if ( + !enabled || + !observations || + !surface?.isConnected || + typeof surface.showPopover !== 'function' || + !isPopoverOpen(surface) + ) { + return; + } + if ([...observations.keys()].some(isFocusInside)) { + focusWasInside = true; + } + let doc = getDocuments().find(document => document.hasFocus()); + let destination: Element | undefined; + let inside = false; + while (doc) { + const active = getDeepActiveElement(doc); + if (!active) { + break; + } + inside ||= isFocusInside(active); + if (active !== doc.body && active !== doc.documentElement) { + destination = active; + } + const child = active.nodeName === 'IFRAME' ? (active as HTMLIFrameElement).contentDocument : null; + if (!child?.hasFocus()) { + break; + } + observeRoot(child); + doc = child; + } + if (!getFocusedFrame()) { + stopFramePoll(); + } else if (!framePoll) { + const win = surface.ownerDocument.defaultView; + if (win) { + const poll = { doc: surface.ownerDocument, id: 0 }; + framePoll = poll; + // One scoped sampler covers frame transitions without parent focus events. + poll.id = win.setInterval(() => { + if (framePoll !== poll) { + return; + } + const frame = getFocusedFrame(); + const currentWindow = frame?.ownerDocument.defaultView; + if (generation !== epoch || !frame || !currentWindow) { + stopFramePoll(); + return; + } + frame.dispatchEvent(new currentWindow.CustomEvent('fuiframefocus', { bubbles: true, composed: true })); + }, 100); + } + } + if (inside) { + focusWasInside = true; + return; + } + if (!destination) { + return; + } + const event = observations.get(destination) ?? [...observations.values()].pop(); + if (focusWasInside && event) { + focusWasInside = false; + // Consumer code may synchronously close or unmount; allocate nothing after this callback. + setOpen(event, false); + } + }; + + function scheduleFocusReconciliation() { + if (!focusObservations) { + focusObservations = new Map(); + Promise.resolve().then(reconcileFocus); + } + return focusObservations; + } + + function observe(event: Event, target: Node | null) { + if (enabled === null || (target && !target.isConnected)) { + return; + } + const previousGeneration = eventGenerations.get(event); + if (previousGeneration !== undefined && previousGeneration !== generation) { + return; + } + eventGenerations.set(event, generation); + const observations = scheduleFocusReconciliation(); + observations.delete(target); + observations.set(target, event); + if (target) { + observeRootChain(target); + } + } + + function observeRootChain(target: Node) { + let current = target; + while (true) { + const root = current.getRootNode(); + observeRoot(root); + if (root.nodeType !== 11 || !('host' in root)) { + break; + } + const shadowRoot = root as ShadowRoot; + knownShadowRoots.set(shadowRoot.host, shadowRoot); + current = shadowRoot.host; + } + if (target.ownerDocument) { + observeRoot(target.ownerDocument); + } + } + + function observeRoot(root: Node) { + const ownerDocument = root.nodeType === 9 ? (root as Document) : root.ownerDocument; + if ( + enabled === null || + focusObservers.has(root) || + !ownerDocument?.defaultView || + (retiredFocusDocuments.has(ownerDocument) && !ownerDocument.hasFocus()) + ) { + return; + } + retiredFocusDocuments.delete(ownerDocument); + const onFocus = (event: Event) => observe(event, (event.composedPath()[0] ?? event.target) as Node | null); + const doc = root.nodeType === 9 ? (root as Document) : undefined; + let timeout: number | undefined; + let disposed = false; + const onWindowBlur = (event: Event) => { + const currentWindow = doc?.defaultView; + const epoch = generation; + if (!disposed && currentWindow) { + currentWindow.clearTimeout(timeout); + timeout = currentWindow.setTimeout(() => { + if (!disposed && generation === epoch) { + observe(event, null); + } + }, 0); + } + }; + const onWindowFocus = (event: Event) => observe(event, null); + const onPageHide = (event: Event) => { + if (doc && !(event as PageTransitionEvent).persisted) { + retiredFocusDocuments.add(doc); + focusObservers.forEach((dispose, observedRoot) => { + if (observedRoot === doc || observedRoot.ownerDocument === doc) { + dispose(); + } + }); + } + }; + const updateListeners = (method: 'addEventListener' | 'removeEventListener') => { + root[method]('focusin', onFocus, true); + root[method]('fuiframefocus', onFocus, true); + const currentWindow = doc?.defaultView; + currentWindow?.[method]('blur', onWindowBlur); + currentWindow?.[method]('focus', onWindowFocus); + currentWindow?.[method]('pagehide', onPageHide); + return currentWindow; + }; + updateListeners('addEventListener'); + focusObservers.set(root, () => { + if (disposed) { + return; + } + disposed = true; + // A captured WindowProxy can become cross-origin after its document navigates. + const currentWindow = updateListeners('removeEventListener'); + currentWindow?.clearTimeout(timeout); + if (doc === framePoll?.doc) { + stopFramePoll(); + } + focusObservers.delete(root); + }); + } + + return { + observe, + update( + nextEnabled: boolean, + nextContent: HTMLElement | null, + nextTrigger: HTMLElement | null, + onExit: (event: Event, open: boolean) => void, + ) { + if (content !== nextContent) { + stopFramePoll(); + } + content = nextContent; + trigger = nextTrigger; + setOpen = onExit; + enabled = nextEnabled; + if (!enabled) { + generation++; + focusWasInside = false; + focusObservations?.clear(); + focusObservers.forEach(dispose => dispose()); + focusObservers.clear(); + knownShadowRoots.clear(); + return; + } + // Surviving listeners and learned portal roots stay attached across commits. + [content, trigger].forEach(element => { + if (element) { + observeRootChain(element); + if (isFocusInside(getDeepActiveElement(element.ownerDocument))) { + focusWasInside = true; + } + } + }); + knownShadowRoots.forEach(root => { + observeRoot(root); + observeRoot(root.ownerDocument); + }); + scheduleFocusReconciliation(); + }, + + dispose() { + enabled = null; + generation++; + focusObservations?.clear(); + stopFramePoll(); + focusObservers.forEach(dispose => dispose()); + focusObservers.clear(); + }, + }; }; /** @@ -51,15 +329,6 @@ export const usePopover = (props: PopoverProps): PopoverState => { const focusWasInsideOnNativeClose = React.useRef(null); const openedSurface = React.useRef(null); const pendingNativeClose = React.useRef(null); - const focusWasInside = React.useRef(false); - const focusObservers = React.useRef(new Map void>()); - const knownShadowRoots = React.useRef(new Map()); - const focusObservations = React.useRef([]); - const focusReconciliationQueued = React.useRef(false); - const framePolls = React.useRef(new Map()); - const observationLifecycle = React.useRef({ mounted: true, generation: 0 }); - const eventGenerations = React.useRef(new WeakMap()); - const retiredFocusDocuments = React.useRef(new WeakSet()); const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined); @@ -131,35 +400,27 @@ export const usePopover = (props: PopoverProps): PopoverState => { setShouldRenderSurface(false); }); - const onSurfaceToggle = useEventCallback((event: Event) => { + const onSurfaceChange = useEventCallback((event: Event) => { const toggle = event as ToggleEvent; - const nextOpen = toggle.newState === 'open'; + const nextOpen = event.type === 'toggle' && toggle.newState === 'open'; const surface = contentRef.current; - if (event.currentTarget !== surface || !surface || surface.matches(':popover-open') !== nextOpen) { + if ( + event.currentTarget !== surface || + !surface || + (event.type === 'close' ? isDialogElement(surface) && surface.open : isPopoverOpen(surface) !== nextOpen) + ) { return; } + // Legacy React roots commit each update synchronously; close before releasing the surface. + if (nextOpen !== open) { + setOpenState(nextOpen); + } if (!nextOpen) { finishSurfaceClose(); } - if (nextOpen === open) { - return; + if (nextOpen !== open) { + onOpenChange(event, nextOpen); } - - setOpenState(nextOpen); - props.onOpenChange?.(event, { event, type: event.type, open: nextOpen }); - }); - - const onSurfaceClose = useEventCallback((event: Event) => { - const surface = contentRef.current; - if (event.currentTarget !== surface || (isDialogElement(surface) && surface.open)) { - return; - } - finishSurfaceClose(); - if (!open) { - return; - } - setOpenState(false); - props.onOpenChange?.(event, { event, type: event.type, open: false }); }); const renderSurface = open || shouldRenderSurface; @@ -172,12 +433,12 @@ export const usePopover = (props: PopoverProps): PopoverState => { } const trackNativeCloseFocus = (event: Event) => { if (event.type === 'cancel' || (event as ToggleEvent).newState === 'closed') { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); } if (!trapFocus && open && (event as ToggleEvent).newState === 'closed') { pendingNativeClose.current = surface; Promise.resolve().then(() => { - if (pendingNativeClose.current === surface && surface.matches(':popover-open')) { + if (pendingNativeClose.current === surface && isPopoverOpen(surface)) { pendingNativeClose.current = null; } }); @@ -195,12 +456,12 @@ export const usePopover = (props: PopoverProps): PopoverState => { const trackDialogFocusOut = () => { focusWasInsideOnNativeClose.current = surface.open ? null : true; }; - surface.addEventListener('close', onSurfaceClose); + surface.addEventListener('close', onSurfaceChange); surface.addEventListener('cancel', trackNativeCloseFocus); surface.addEventListener('beforetoggle', trackNativeCloseFocus); surface.addEventListener('focusout', trackDialogFocusOut); return () => { - surface.removeEventListener('close', onSurfaceClose); + surface.removeEventListener('close', onSurfaceChange); surface.removeEventListener('cancel', trackNativeCloseFocus); surface.removeEventListener('beforetoggle', trackNativeCloseFocus); surface.removeEventListener('focusout', trackDialogFocusOut); @@ -211,10 +472,10 @@ export const usePopover = (props: PopoverProps): PopoverState => { return; } - surface.addEventListener('toggle', onSurfaceToggle); + surface.addEventListener('toggle', onSurfaceChange); surface.addEventListener('beforetoggle', trackNativeCloseFocus); return () => { - surface.removeEventListener('toggle', onSurfaceToggle); + surface.removeEventListener('toggle', onSurfaceChange); surface.removeEventListener('beforetoggle', trackNativeCloseFocus); }; }); @@ -228,7 +489,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { if (!shouldRenderSurface) { setShouldRenderSurface(true); } - if (pendingNativeClose.current === surface && !surface.matches(':popover-open')) { + if (pendingNativeClose.current === surface && !isPopoverOpen(surface)) { return; } focusWasInsideOnNativeClose.current = null; @@ -240,9 +501,9 @@ export const usePopover = (props: PopoverProps): PopoverState => { if (isDialogElement(surface) && surface.open) { openedSurface.current = surface; } - } else if (typeof surface.showPopover === 'function' && !surface.matches(':popover-open')) { + } else if (typeof surface.showPopover === 'function' && !isPopoverOpen(surface)) { surface.showPopover(); - if (surface.matches(':popover-open')) { + if (isPopoverOpen(surface)) { openedSurface.current = surface; } } @@ -250,333 +511,35 @@ export const usePopover = (props: PopoverProps): PopoverState => { } if (trapFocus) { if (isDialogElement(surface) && surface.open) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); surface.close(); } else if (openedSurface.current !== surface) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); finishSurfaceClose(); } } else if (typeof surface.hidePopover === 'function') { - if (surface.matches(':popover-open')) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + if (isPopoverOpen(surface)) { + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); surface.hidePopover(); } else if (openedSurface.current !== surface) { finishSurfaceClose(); } } else { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); finishSurfaceClose(); } }, [open, trapFocus, shouldRenderSurface, props.children, contentRef, finishSurfaceClose]); - const isFocusInside = React.useCallback((target: Node | null) => { - let current = target; - const visited = new Set(); - while (current && !visited.has(current)) { - visited.add(current); - if (elementContains(contentRef.current, current) || elementContains(triggerRef.current, current)) { - return true; - } - current = current.ownerDocument?.defaultView?.frameElement ?? null; - } - return false; - }, []); - const getDeepActiveElement = (doc: Document): Element | null => { - let active = doc.activeElement; - const visited = new Set(); - while (active && !visited.has(active)) { - visited.add(active); - const root = active.shadowRoot ?? knownShadowRoots.current.get(active); - if (!root?.activeElement) { - break; - } - active = root.activeElement; - } - return active; - }; - - const getActiveFrameChain = (doc: Document): Array<{ doc: Document; active: Element }> => { - const chain: Array<{ doc: Document; active: Element }> = []; - const visited = new Set(); - let current = doc; - while (!visited.has(current)) { - visited.add(current); - const active = getDeepActiveElement(current); - if (!active) { - break; - } - chain.push({ doc: current, active }); - const childDocument = active.nodeName === 'IFRAME' ? (active as HTMLIFrameElement).contentDocument : null; - if (!childDocument || !childDocument.hasFocus()) { - break; - } - current = childDocument; - } - return chain; - }; - - const reconcileFocus = useEventCallback(() => { - const generation = observationLifecycle.current.generation; - const observations = focusObservations.current.splice(0).filter(item => item.generation === generation); - const surface = contentRef.current; - if ( - !observationLifecycle.current.mounted || - !open || - trapFocus || - !surface || - typeof surface.showPopover !== 'function' - ) { - return; - } - const isCurrentSurface = () => - observationLifecycle.current.mounted && - observationLifecycle.current.generation === generation && - contentRef.current === surface && - surface.isConnected && - surface.matches(':popover-open'); - if (!isCurrentSurface()) { - return; - } - if (observations.some(observation => isFocusInside(observation.target))) { - focusWasInside.current = true; - } - const documents = new Set([surface.ownerDocument, triggerRef.current?.ownerDocument]); - knownShadowRoots.current.forEach(root => documents.add(root.ownerDocument)); - focusObservers.current.forEach((_dispose, root) => { - if (root.nodeType === 9) { - documents.add(root as Document); - } - }); - const focusedRootDocument = - [...observations] - .reverse() - .map(observation => observation.target?.ownerDocument) - .find(doc => doc?.hasFocus()) ?? [...documents].find(doc => doc?.hasFocus()); - const activeChain = focusedRootDocument ? getActiveFrameChain(focusedRootDocument) : []; - activeChain.forEach(item => documents.add(item.doc)); - for (const doc of documents) { - if (!isCurrentSurface()) { - return; - } - if (!doc) { - continue; - } - const win = doc.defaultView; - const active = getDeepActiveElement(doc); - const isFrame = active?.nodeName === 'IFRAME' || active?.nodeName === 'WEBVIEW'; - if (active?.nodeName === 'IFRAME') { - const frameDocument = (active as HTMLIFrameElement).contentDocument; - if (frameDocument) { - if (!isCurrentSurface()) { - return; - } - observeRoot(frameDocument); - } - } - if (win && isFrame && doc.hasFocus() && !framePolls.current.has(doc)) { - // Moving between child frames produces no second parent blur/focusin event. - framePolls.current.set( - doc, - win.setInterval(() => { - const currentWindow = doc.defaultView; - const destination = getDeepActiveElement(doc); - if ( - !observationLifecycle.current.mounted || - observationLifecycle.current.generation !== generation || - !currentWindow || - !doc.hasFocus() || - !destination - ) { - currentWindow?.clearInterval(framePolls.current.get(doc)); - framePolls.current.delete(doc); - return; - } - destination.dispatchEvent( - new currentWindow.CustomEvent('fuiframefocus', { bubbles: true, composed: true }), - ); - }, 100), - ); - } else if (win && (!isFrame || !doc.hasFocus())) { - win.clearInterval(framePolls.current.get(doc)); - framePolls.current.delete(doc); - } - } - if (!isCurrentSurface()) { - return; - } - if (activeChain.some(item => isFocusInside(item.active))) { - focusWasInside.current = true; - return; - } - const destination = [...activeChain] - .reverse() - .find(item => item.active !== item.doc.body && item.active !== item.doc.documentElement); - if (!destination) { - return; - } - const observation = - [...observations].reverse().find(item => item.target === destination.active) ?? - observations[observations.length - 1]; - if (focusWasInside.current && observation) { - focusWasInside.current = false; - // Consumer code may synchronously close or unmount; allocate nothing after this callback. - setOpen(observation.event, false); - } - }); - - function scheduleFocusReconciliation() { - if (!focusReconciliationQueued.current) { - focusReconciliationQueued.current = true; - Promise.resolve().then(() => { - focusReconciliationQueued.current = false; - reconcileFocus(); - }); - } - } - - function observeFocus(event: Event, target: Node | null) { - const lifecycle = observationLifecycle.current; - if (!lifecycle.mounted || (target && !target.isConnected)) { - return; - } - const previousGeneration = eventGenerations.current.get(event); - if (previousGeneration !== undefined && previousGeneration !== lifecycle.generation) { - return; - } - eventGenerations.current.set(event, lifecycle.generation); - focusObservations.current.push({ event, target, generation: lifecycle.generation }); - if (target) { - observeRootChain(target); - } - scheduleFocusReconciliation(); - } - - function observeRootChain(target: Node) { - let current = target; - const visited = new Set(); - while (!visited.has(current)) { - visited.add(current); - const root = current.getRootNode(); - observeRoot(root); - const ShadowRootConstructor = current.ownerDocument?.defaultView?.ShadowRoot; - if (!ShadowRootConstructor || !(root instanceof ShadowRootConstructor)) { - break; - } - knownShadowRoots.current.set(root.host, root); - current = root.host; - } - if (target.ownerDocument) { - observeRoot(target.ownerDocument); - } - } - - function observeRoot(root: Node) { - const ownerDocument = root.nodeType === 9 ? (root as Document) : root.ownerDocument; - if ( - !observationLifecycle.current.mounted || - focusObservers.current.has(root) || - !ownerDocument?.defaultView || - (retiredFocusDocuments.current.has(ownerDocument) && !ownerDocument.hasFocus()) - ) { - return; - } - retiredFocusDocuments.current.delete(ownerDocument); - const onFocus = (event: Event) => observeFocus(event, (event.composedPath()[0] ?? event.target) as Node | null); - const doc = root.nodeType === 9 ? (root as Document) : undefined; - const win = doc?.defaultView; - let timeout: number | undefined; - let disposed = false; - const onWindowBlur = (event: Event) => { - const currentWindow = doc?.defaultView; - const generation = observationLifecycle.current.generation; - if (!disposed && currentWindow) { - currentWindow.clearTimeout(timeout); - timeout = currentWindow.setTimeout(() => { - if (!disposed && observationLifecycle.current.generation === generation) { - observeFocus(event, null); - } - }, 0); - } - }; - const onWindowFocus = (event: Event) => observeFocus(event, null); - const onPageHide = (event: PageTransitionEvent) => { - if (doc && !event.persisted) { - retiredFocusDocuments.current.add(doc); - focusObservers.current.forEach((dispose, observedRoot) => { - if (observedRoot === doc || observedRoot.ownerDocument === doc) { - dispose(); - } - }); - } - }; - root.addEventListener('focusin', onFocus, true); - root.addEventListener('fuiframefocus', onFocus, true); - win?.addEventListener('blur', onWindowBlur); - win?.addEventListener('focus', onWindowFocus); - win?.addEventListener('pagehide', onPageHide); - focusObservers.current.set(root, () => { - if (disposed) { - return; - } - disposed = true; - root.removeEventListener('focusin', onFocus, true); - root.removeEventListener('fuiframefocus', onFocus, true); - // A captured WindowProxy can become cross-origin after its document navigates. - const currentWindow = doc?.defaultView; - currentWindow?.removeEventListener('blur', onWindowBlur); - currentWindow?.removeEventListener('focus', onWindowFocus); - currentWindow?.removeEventListener('pagehide', onPageHide); - currentWindow?.clearTimeout(timeout); - if (doc) { - currentWindow?.clearInterval(framePolls.current.get(doc)); - framePolls.current.delete(doc); - } - focusObservers.current.delete(root); - }); - } - + const [focusObserver] = React.useState(createFocusObserver); const onBoundaryFocusCapture = (event: React.FocusEvent) => { if (open && !trapFocus) { - observeFocus(event.nativeEvent, event.target); + focusObserver.observe(event.nativeEvent, event.target); } }; - - useIsomorphicLayoutEffect(() => { - observationLifecycle.current.mounted = true; - if (!open || trapFocus) { - observationLifecycle.current.generation++; - focusWasInside.current = false; - focusObservations.current = []; - focusObservers.current.forEach(dispose => dispose()); - focusObservers.current.clear(); - knownShadowRoots.current.clear(); - return; - } - // Surviving listeners and learned portal roots stay attached across commits. - [contentRef.current, triggerRef.current].forEach(element => { - if (element) { - observeRootChain(element); - if (isFocusInside(getDeepActiveElement(element.ownerDocument))) { - focusWasInside.current = true; - } - } - }); - knownShadowRoots.current.forEach(root => { - observeRoot(root); - observeRoot(root.ownerDocument); - }); - scheduleFocusReconciliation(); - }); - useIsomorphicLayoutEffect(() => { - const observers = focusObservers.current; - const lifecycle = observationLifecycle.current; - return () => { - lifecycle.mounted = false; - lifecycle.generation++; - observers.forEach(dispose => dispose()); - observers.clear(); - }; - }, []); + useIsomorphicLayoutEffect(() => + focusObserver.update(open && !trapFocus, contentRef.current, triggerRef.current, setOpen), + ); + useIsomorphicLayoutEffect(() => () => focusObserver.dispose(), [focusObserver]); const children = React.Children.toArray(props.children) as React.ReactElement[]; diff --git a/yarn.lock b/yarn.lock index 3e668d5ad981b..bbf9591842166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4480,7 +4480,7 @@ __metadata: "@fluentui/react-overflow": "npm:^9.9.5" "@fluentui/react-persona": "npm:^9.7.11" "@fluentui/react-popover": "npm:^9.14.10" - "@fluentui/react-portal": "npm:^9.8.17" + "@fluentui/react-portal": "npm:*" "@fluentui/react-positioning": "npm:^9.23.4" "@fluentui/react-progress": "npm:^9.5.7" "@fluentui/react-provider": "npm:^9.22.22" From e9260d0c3fb118822c4916bfa071faff09dc6595 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 26 Oct 2026 12:01:34 +0100 Subject: [PATCH 08/12] fix(react-headless-components-preview): preserve hover and ancestor-frame focus Keep hover-triggered content visible on keyboard focus exit while its trigger or surface remains hovered. Observe reachable same-origin ancestor documents and resolve the live focused frame chain, excluding embedding-only frames from sampling while retaining ancestor-portal opaque transitions. Add native controlled-state regressions and runnable Storybook examples without expanding the public API or bundle budget. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44bec1f8-d9c2-4333-a285-7270b37fe5da --- ...-a73dd995-4801-4e2d-a38e-22b587c50e43.json | 2 +- .../library/docs/popover-spec.md | 4 +- .../components/Popover/PopoverBoundary.cy.tsx | 199 ++++++++++++++++++ .../src/components/Popover/usePopover.ts | 93 ++++---- .../stories/src/Popover/PopoverDescription.md | 4 +- .../src/Popover/PopoverInFrame.stories.tsx | 33 +++ .../Popover/PopoverOpenOnHover.stories.tsx | 28 +-- .../stories/src/Popover/index.stories.tsx | 1 + 8 files changed, 305 insertions(+), 59 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverBoundary.cy.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInFrame.stories.tsx diff --git a/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json index d9a4e951819dc..d1c3b3802bb49 100644 --- a/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json +++ b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: automatically dismiss non-modal Popover when focus leaves its logical boundary", + "comment": "fix: dismiss non-modal Popover on focus exit across same-origin frames while preserving active hover and destination focus", "packageName": "@fluentui/react-headless-components-preview", "email": "paulmardling@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index 5e85fbff74135..0df2d936b251f 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -159,11 +159,11 @@ Native opening, trapping, and closing use the platform APIs, with the close life When `trapFocus={false}`, focus moving outside the logical boundary automatically requests closing through `onOpenChange`. This is built-in accessibility behavior: no additional prop or consumer focus handler is required. Controlled consumers continue to accept or reject open-state requests through the existing `open` prop. Focus observations are reconciled against the live destination after layout and portal ownership have settled, rather than classified by capture-handler arrival order. Closing preserves focus on that destination rather than moving it back to the trigger. -The boundary includes the trigger, surface, and their DOM, shadow, and Fluent virtual-parent descendants. Moving between those members, including back to the trigger, keeps the popover open. Unregistered React portals are not automatically owned descendants. A trigger focused at opening immediately establishes boundary focus, including for text-only surfaces. Hover or programmatic opening with focus elsewhere waits for focus to enter the boundary before an exit can dismiss. +The boundary includes the trigger, surface, and their DOM, shadow, and Fluent virtual-parent descendants. Moving between those members, including back to the trigger, keeps the popover open. Unregistered React portals are not automatically owned descendants. A trigger focused at opening immediately establishes boundary focus, including for text-only surfaces. Hover or programmatic opening with focus elsewhere waits for focus to enter the boundary before an exit can dismiss. With `openOnHover`, focus exit does not dismiss while the pointer still hovers the trigger or surface; the existing delayed mouse-leave behavior applies after the pointer leaves. Window blur and an unknown destination (`relatedTarget={null}`) alone do not request dismissal. A subsequent confirmed focus move outside can dismiss; unrelated external focus movement on a popover that has never owned focus cannot. -Tabbing into an outside iframe is a confirmed destination and dismisses the popover while preserving focus inside the frame. An iframe within the logical boundary stays owned. Accessible same-origin frame documents are observed directly. While a known frame holds focus, one scoped 100 ms sampler per Popover covers transitions that do not produce parent focus events, including cross-origin frame-to-frame navigation. A complete inside/outside round trip between samples in an inaccessible frame can be missed; instantaneous or universal cross-origin detection is not promised. Sampling stops when frame focus ends, the surface is replaced, or the popover closes/unmounts. Observed-frame callbacks use the existing `fuiframefocus` event semantics. +Tabbing into an outside iframe is a confirmed destination and dismisses the popover while preserving focus inside the frame. An iframe within the logical boundary stays owned. Accessible same-origin frame documents and ancestor documents are observed directly, including when a Popover hosted in an iframe loses focus to a parent-page control. Merely hosting a Popover in an iframe does not start sampling. While a descendant frame holds focus, one scoped 100 ms sampler per Popover covers transitions that do not produce parent focus events, including cross-origin frame-to-frame navigation. A complete inside/outside round trip between samples in an inaccessible frame can be missed; instantaneous or universal cross-origin detection is not promised. Sampling stops when frame focus ends, the surface is replaced, or the popover closes/unmounts. Observed-frame callbacks use the existing `fuiframefocus` event semantics. Accessible active frame chains are resolved to their focused control, so a virtually owned portal inside an otherwise external iframe remains within the boundary. Navigation retires discarded documents' subscriptions without accessing their former windows across origins. Cached documents retain observation while frozen, so Back/Forward restoration does not require a React commit to resume dismissal. Cleanup also covers synchronous unmounts from `onOpenChange`; stale focus captures cannot recreate subscriptions or sampling afterward. diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverBoundary.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverBoundary.cy.tsx new file mode 100644 index 0000000000000..72d7e168d67ed --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverBoundary.cy.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Portal } from '@fluentui/react-portal'; +import { mount } from '@fluentui/scripts-cypress'; +import { Popover } from './Popover'; +import { PopoverSurface } from './PopoverSurface/PopoverSurface'; +import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; +describe('Popover hover and ancestor-document boundaries', () => { + beforeEach(() => cy.get('body').realClick({ position: 'bottomRight' })); + + (['uncontrolled', 'accept', 'reject'] as const).forEach(mode => { + (['trigger', 'surface'] as const).forEach(hoverTarget => { + it(`preserves ${hoverTarget} hover after Tab-out (${mode})`, () => { + const change = cy.spy().as('change'); + const Example = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + { + change(event, data); + if (mode === 'accept' || data.open) { + setOpen(data.open); + } + }} + > + + + + + + + + + + ); + }; + mount(); + cy.get('[data-testid=trigger]').realHover(); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('[data-testid=inside]').focus(); + cy.get(`[data-testid=${hoverTarget}]`) + .realHover({ position: 'left' }) + .should(element => { + expect(element[0].matches(':hover')).to.equal(true); + }); + cy.get('@change').invoke('resetHistory'); + cy.realPress('Tab'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get(`[data-testid=${hoverTarget}]`).should(element => { + expect(element[0].matches(':hover')).to.equal(true); + }); + cy.get('[data-testid=surface]').should('be.visible'); + cy.get('@change').should('not.have.been.called'); + cy.get('body').realHover({ position: 'bottomRight' }); + cy.get('@change').should('have.been.calledOnce'); + cy.get('@change').its('firstCall.args.1').should('include', { type: 'mouseleave', open: false }); + cy.get('[data-testid=surface]').should(mode === 'reject' ? 'be.visible' : 'not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + }); + }); + }); + + (['uncontrolled', 'reject'] as const).forEach(mode => { + it(`samples owned opaque frames portalled into an ancestor document (${mode})`, () => { + const change = cy.spy().as('change'); + const loaded = cy.spy().as('loaded'); + const frameUrl = 'data:text/html,'; + const Example = () => { + const [doc, setDocument] = React.useState(null); + const [mountNode, setMountNode] = React.useState(null); + return ( + <> +