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 00000000000000..55ceb159f6bf38 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-a73dd995-4801-4e2d-a38e-22b587c50e43.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: dismiss non-modal Popover on focus exit across same-origin frames, preserving logical Portal hover, initial trigger Portal focus, and destination focus", + "packageName": "@fluentui/react-headless-components-preview", + "email": "paulmardling@microsoft.com" +} diff --git a/change/@fluentui-react-portal-a79d9baf-03ac-4063-8281-fd57a169d3ca.json b/change/@fluentui-react-portal-a79d9baf-03ac-4063-8281-fd57a169d3ca.json new file mode 100644 index 00000000000000..ef69bc9df2d455 --- /dev/null +++ b/change/@fluentui-react-portal-a79d9baf-03ac-4063-8281-fd57a169d3ca.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: establish portal virtual ownership during layout for focus observers", + "packageName": "@fluentui/react-portal", + "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 5ae530391ed1a6..3d8c9d179590ee 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 automatic focus-exit dismissal also flow through React before native closing. When `trapFocus` is enabled, the default `` surface opens modally with `role="dialog"`. ## Composition @@ -53,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 @@ -74,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 @@ -87,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 @@ -150,11 +150,27 @@ 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. +#### Automatic focus-exit dismissal + +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. Focus captured in a trigger-owned Portal before opening is remembered without installing closed-state listeners or timers; opening learns its shadow roots before the first exit. Ownership is still determined from live focus, not a stale captured target. 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 React reports hover within the trigger or surface branch, including owned Portals outside their physical DOM; 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 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. + +Owned React/Fluent portals beneath either the trigger or surface retain their logical focus destination through React focus capture. Their observed roots remain subscribed for the open lifetime, including open or closed external roots, so moves to an unowned sibling in the same root are observed. Fluent Portal establishes its virtual parent during layout, before post-commit focus reconciliation. 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. + +Native focus observation remains attached across ordinary commits so descendant layout-effect focus moves are not lost. Native close intent also remains authoritative until its queued close event is reconciled; a simultaneous content update does not reopen an uncontrolled surface or lose the saved restoration ownership when the focused child is replaced. + #### 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 +220,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 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) @@ -322,8 +338,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. 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; 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/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 2158f21ad76fe6..db1138075eac02 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -662,6 +662,7 @@ ] }, "devDependencies": { + "@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/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index 064f7935c7c202..eabb9ec4bbc7a5 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 83d6cee83dc06c..67b1fef0a0171b 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,8 +6,10 @@ 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'; +import { PopoverFocusExample, useTestPopoverOpen } from '../../testing/PopoverTestFixtures'; const mount = (element: JSXElement) => { mountBase(element); @@ -16,6 +18,356 @@ const mount = (element: JSXElement) => { const popoverTriggerSelector = '[aria-expanded]'; const popoverContentSelector = '[role="group"]'; +describe('automatic focus-exit dismissal', () => { + const Example = ({ + textOnly = false, + mode = 'uncontrolled', + onOpenChange, + }: { + textOnly?: boolean; + mode?: 'uncontrolled' | 'accept' | 'reject'; + onOpenChange?: PopoverProps['onOpenChange']; + }) => ; + + ( + [ + [false, 'uncontrolled'], + [true, 'uncontrolled'], + [false, 'accept'], + [false, 'reject'], + ] as const + ).forEach(([textOnly, 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('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', () => { + 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'); + }); + + (['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 openProps = useTestPopoverOpen(mode, true, change); + return ( + <> + + + + + + ' + } + /> + + ); + }; + 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 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(); + 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 = 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, 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/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index 84faf520ee4948..efc2f5163dce93 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts @@ -2,7 +2,7 @@ import type * as React from 'react'; import { useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities'; -import { usePopoverContext } from '../popoverContext'; +import { usePopoverContext, usePopoverCaptureProps } from '../popoverContext'; import { toDataAttributeValue } from '../../../utils'; import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types'; @@ -55,6 +55,7 @@ export const usePopoverSurface = ( }; const { onMouseEnter: onMouseEnterOriginal, onMouseLeave: onMouseLeaveOriginal } = state.root; + Object.assign(state.root, usePopoverCaptureProps(state.root)); state.root.onMouseEnter = useEventCallback((e: React.MouseEvent) => { if (openOnHover) { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts index c9c517bcc4455b..6cdc8bc116bcfc 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts @@ -12,7 +12,7 @@ import { import { useARIAButtonProps } from '@fluentui/react-aria'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { Escape } from '@fluentui/keyboard-keys'; -import { usePopoverContext } from '../popoverContext'; +import { usePopoverContext, usePopoverCaptureProps } from '../popoverContext'; import { toDataAttributeValue } from '../../../utils'; import type { PopoverTriggerChildProps, PopoverTriggerProps, PopoverTriggerState } from './PopoverTrigger.types'; @@ -22,7 +22,9 @@ import type { PopoverTriggerChildProps, PopoverTriggerProps, PopoverTriggerState */ export const usePopoverTrigger = (props: PopoverTriggerProps): PopoverTriggerState => { const { children, disableButtonEnhancement = false } = props; - const child = getTriggerChild(children); + const child = getTriggerChild< + PopoverTriggerChildProps & Pick, 'onFocusCapture' | 'onMouseOverCapture'> + >(children); const open = usePopoverContext(context => context.open); const setOpen = usePopoverContext(context => context.setOpen); @@ -32,6 +34,7 @@ export const usePopoverTrigger = (props: PopoverTriggerProps): PopoverTriggerSta const openOnContext = usePopoverContext(context => context.openOnContext); const positioningCtx = usePopoverContext(context => context.positioning); const surfaceId = usePopoverContext(context => context.surfaceId); + const captureProps = usePopoverCaptureProps(child?.props); const { targetDocument } = useFluent(); @@ -80,6 +83,7 @@ export const usePopoverTrigger = (props: PopoverTriggerProps): PopoverTriggerSta 'aria-details': open ? surfaceId : undefined, 'data-open': toDataAttributeValue(open), ...child?.props, + ...captureProps, onMouseEnter: useEventCallback(mergeCallbacks(child?.props.onMouseEnter, onMouseEnter)), onMouseLeave: useEventCallback(mergeCallbacks(child?.props.onMouseLeave, onMouseLeave)), onContextMenu: useEventCallback(mergeCallbacks(child?.props.onContextMenu, onContextMenu)), diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts index 30fc3c12125179..4f1c20f74a7700 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts @@ -1,9 +1,15 @@ 'use client'; import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import { mergeCallbacks } from '@fluentui/react-utilities'; +import type * as React from 'react'; import type { ContextSelector, Context } from '@fluentui/react-context-selector'; import type { PopoverContextValue } from './Popover.types'; +export type InternalPopoverContextValue = PopoverContextValue & { + capture?: React.EventHandler | React.MouseEvent>; +}; + export const PopoverContext: Context = createContext( undefined, ) as Context; @@ -33,3 +39,13 @@ export const PopoverProvider = PopoverContext.Provider; */ export const usePopoverContext = (selector: ContextSelector): T => useContextSelector(PopoverContext, (ctx = popoverContextDefaultValue) => selector(ctx)); + +type CaptureProps = Pick, 'onFocusCapture' | 'onMouseOverCapture'>; + +export const usePopoverCaptureProps = (props?: CaptureProps): CaptureProps => { + const capture = useContextSelector(PopoverContext, ctx => (ctx as InternalPopoverContextValue | undefined)?.capture); + return { + onFocusCapture: mergeCallbacks<[React.FocusEvent]>(capture, props?.onFocusCapture), + onMouseOverCapture: mergeCallbacks<[React.MouseEvent]>(capture, props?.onMouseOverCapture), + }; +}; 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 582614f9329f2c..8db4bbaa0fb1f5 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,22 +7,326 @@ import { useId, useTimeout, useIsomorphicLayoutEffect, + elementContains, } from '@fluentui/react-utilities'; import { usePositioning, resolvePositioningShorthand } from '../../hooks'; import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types'; +import type { InternalPopoverContextValue } from './popoverContext'; type ToggleEvent = Event & { newState?: 'open' | 'closed' }; +type InternalPopoverState = PopoverState & Pick; 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 getFrameElement = (doc: Document | null) => doc && doc.defaultView?.frameElement; +const isFrameElement = (element: Element | null) => + !!element && (element.nodeName === 'IFRAME' || element.nodeName === 'WEBVIEW'); + +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 hoveredTarget: Node | null = null; + let focusTarget: Node | null = null; + let focusWasInside = false; + const focusObservers = new Map void>(); + const knownShadowRoots = new Map(); + let focusObservations: Map | undefined; + let framePoll: { doc: Document; id: number } | undefined; + let frameDocument: Document | undefined; + let generation = 0; + const eventGenerations = new WeakMap(); + const retiredFocusDocuments = new WeakSet(); + + const isFocusInside = (current: Node | null | undefined) => { + while (current) { + if (elementContains(content, current) || elementContains(trigger, current)) { + return true; + } + current = getFrameElement(current.ownerDocument); + } + return false; + }; + const getDeepActiveElement = (doc: Document): Element | null => { + let active = doc.activeElement; + while (active) { + const next = (active.shadowRoot ?? knownShadowRoots.get(active))?.activeElement; + if (!next) { + break; + } + active = next; + } + return active; + }; + + const getFocusedFrame = () => { + const doc = frameDocument; + const active = doc?.hasFocus() ? getDeepActiveElement(doc) : null; + if (isFrameElement(active)) { + 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 = surface.ownerDocument; + let parentFrame = getFrameElement(doc); + while (parentFrame) { + doc = parentFrame.ownerDocument; + parentFrame = getFrameElement(doc); + } + let destination: Element | undefined; + let inside = false; + frameDocument = undefined; + while (doc.hasFocus()) { + const active = getDeepActiveElement(doc); + if (!active) { + break; + } + if (doc === surface.ownerDocument) { + frameDocument = undefined; + } + if (isFrameElement(active)) { + frameDocument = doc; + } + inside ||= isFocusInside(active); + if (active !== doc.body && active !== doc.documentElement) { + destination = active; + } + const child = active.nodeName === 'IFRAME' ? (active as HTMLIFrameElement).contentDocument : null; + if (!child) { + 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; + } + const event = destination && (observations.get(destination) ?? [...observations.values()].pop()); + if (focusWasInside && event && !(hoveredTarget?.isConnected && isFocusInside(hoveredTarget))) { + 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 record(event: Event, target: Node | null, closed?: boolean) { + if (enabled === null || (target && !target.isConnected)) { + return; + } + if (target) { + focusTarget = target; + } + if (closed) { + return; + } + if ((eventGenerations.get(event) ?? generation) !== generation) { + return; + } + eventGenerations.set(event, generation); + const observations = scheduleFocusReconciliation(); + observations.delete(target); + observations.set(target, event); + if (target) { + observeRootChain(target); + } + } + + function observeRootChain(current: Node | null | undefined) { + while (current) { + const root = current.getRootNode(); + observeRoot(root); + if ('host' in root) { + const shadowRoot = root as ShadowRoot; + knownShadowRoots.set(shadowRoot.host, shadowRoot); + current = shadowRoot.host; + } else { + current = getFrameElement(root as Document); + } + } + } + + function observeRoot(root: Node) { + const doc = root.nodeType === 9 ? (root as Document) : undefined; + const ownerDocument = root.ownerDocument ?? doc; + if ( + enabled === null || + focusObservers.has(root) || + !ownerDocument?.defaultView || + (retiredFocusDocuments.has(ownerDocument) && !ownerDocument.hasFocus()) + ) { + return; + } + retiredFocusDocuments.delete(ownerDocument); + const onFocus = (event: Event) => record(event, (event.composedPath()[0] ?? event.target) as Node | null); + let timeout: number | undefined; + let disposed = false; + const onWindowFocusChange = (event: Event) => { + const currentWindow = doc?.defaultView; + const epoch = generation; + if (!disposed && currentWindow) { + currentWindow.clearTimeout(timeout); + timeout = currentWindow.setTimeout(() => { + if (!disposed && generation === epoch) { + record(event, null); + } + }, 0); + } + }; + 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; + if (currentWindow) { + currentWindow[method]('blur', onWindowFocusChange); + currentWindow[method]('focus', onWindowFocusChange); + 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); + }); + } + + function hover(target: EventTarget | null) { + if (enabled !== null) { + hoveredTarget = target && 'nodeType' in target ? (target as Node) : null; + } + } + function sync( + nextEnabled: boolean, + nextContent: HTMLElement | null, + nextTrigger: HTMLElement | null, + onExit: (event: Event, open: boolean) => void, + allowHover: boolean, + ) { + if (content !== nextContent) { + stopFramePoll(); + } + content = nextContent; + trigger = nextTrigger; + setOpen = onExit; + enabled = nextEnabled; + if (!allowHover) { + hoveredTarget = null; + } + if (!enabled) { + generation++; + focusWasInside = false; + focusObservations?.clear(); + focusObservers.forEach(dispose => dispose()); + knownShadowRoots.clear(); + return; + } + if (focusTarget?.isConnected) { + observeRootChain(focusTarget); + } + // 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(observeRootChain); + scheduleFocusReconciliation(); + } + + function stop() { + enabled = null; + // Passive focus/root knowledge survives Strict Mode effect replay; subscriptions do not. + hoveredTarget = null; + generation++; + focusObservations?.clear(); + stopFramePoll(); + focusObservers.forEach(dispose => dispose()); + } + return [record, hover, sync, stop] as const; }; /** @@ -46,16 +350,18 @@ export const usePopover = (props: PopoverProps): PopoverState => { const [shouldRenderSurface, setShouldRenderSurface] = React.useState(open); const focusWasInsideOnNativeClose = React.useRef(null); const openedSurface = React.useRef(null); + const pendingNativeClose = React.useRef(null); const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined); + const [[recordFocus, recordHover, syncObserver, stopObserver]] = React.useState(createFocusObserver); - const onOpenChange = useEventCallback((e: OpenPopoverEvents, shouldOpen: boolean) => { + const onOpenChange = useEventCallback((e: OpenPopoverEvents | Event, 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 | Event, shouldOpen: boolean) => { clearOpenTimeout(); if (shouldOpen && e.type === 'contextmenu') { @@ -67,17 +373,18 @@ export const usePopover = (props: PopoverProps): PopoverState => { setContextTarget(undefined); } + const updateOpen = () => { + setOpenState(shouldOpen); + onOpenChange(e, shouldOpen); + }; if (e.type === 'mouseleave') { - setOpenTimeout(() => { - setOpenState(shouldOpen); - onOpenChange(e, shouldOpen); - }, mouseLeaveDelay); + recordHover(null); + setOpenTimeout(updateOpen, mouseLeaveDelay); } else { if (shouldOpen && props.open === undefined) { setShouldRenderSurface(true); } - setOpenState(shouldOpen); - onOpenChange(e, shouldOpen); + updateOpen(); } }); @@ -103,48 +410,42 @@ export const usePopover = (props: PopoverProps): PopoverState => { return; } const activeElement = getActiveElement(surface); + const inside = surface.contains(activeElement); // Initially-open surfaces have no native trigger snapshot; preserve successful native restoration. if ( - (focusWasInsideOnNativeClose.current ?? surface.contains(activeElement)) && - (!activeElement || activeElement === surface.ownerDocument.body || surface.contains(activeElement)) && + (focusWasInsideOnNativeClose.current ?? inside) && + (!activeElement || activeElement === surface.ownerDocument.body || inside) && triggerRef.current?.isConnected ) { triggerRef.current.focus(); } focusWasInsideOnNativeClose.current = null; + pendingNativeClose.current = null; openedSurface.current = null; 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; @@ -156,8 +457,19 @@ export const usePopover = (props: PopoverProps): PopoverState => { return; } const trackNativeCloseFocus = (event: Event) => { - if (event.type === 'cancel' || (event as ToggleEvent).newState === 'closed') { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + const nextState = (event as ToggleEvent).newState; + if (event.type === 'cancel' || nextState === 'closed') { + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); + } + if (!trapFocus && open && nextState === 'closed') { + pendingNativeClose.current = surface; + Promise.resolve().then(() => { + if (pendingNativeClose.current === surface && isPopoverOpen(surface)) { + pendingNativeClose.current = null; + } + }); + } else if (nextState === 'open') { + pendingNativeClose.current = null; } }; @@ -170,12 +482,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); @@ -186,10 +498,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); }; }); @@ -203,7 +515,11 @@ export const usePopover = (props: PopoverProps): PopoverState => { if (!shouldRenderSurface) { setShouldRenderSurface(true); } + if (pendingNativeClose.current === surface && !isPopoverOpen(surface)) { + return; + } focusWasInsideOnNativeClose.current = null; + pendingNativeClose.current = null; if (trapFocus) { if (isDialogElement(surface) && !surface.open) { surface.showModal(); @@ -211,35 +527,52 @@ 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; } } return; } + const captureCloseFocus = () => { + focusWasInsideOnNativeClose.current = hasSurfaceFocus(surface); + }; if (trapFocus) { if (isDialogElement(surface) && surface.open) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + captureCloseFocus(); surface.close(); } else if (openedSurface.current !== surface) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + captureCloseFocus(); finishSurfaceClose(); } } else if (typeof surface.hidePopover === 'function') { - if (surface.matches(':popover-open')) { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + if (isPopoverOpen(surface)) { + captureCloseFocus(); surface.hidePopover(); } else if (openedSurface.current !== surface) { finishSurfaceClose(); } } else { - focusWasInsideOnNativeClose.current = surface.contains(getActiveElement(surface)); + captureCloseFocus(); finishSurfaceClose(); } }, [open, trapFocus, shouldRenderSurface, props.children, contentRef, finishSurfaceClose]); + const onBoundaryCapture: NonNullable = event => { + if (!trapFocus) { + if (event.type === 'focus') { + recordFocus(event.nativeEvent, event.target as Node, !open); + } else if (openOnHover) { + recordHover(event.target); + } + } + }; + useIsomorphicLayoutEffect(() => + syncObserver(open && !trapFocus, contentRef.current, triggerRef.current, setOpen, openOnHover), + ); + useIsomorphicLayoutEffect(() => stopObserver, [stopObserver]); + const children = React.Children.toArray(props.children) as React.ReactElement[]; if (process.env.NODE_ENV !== 'production') { @@ -264,7 +597,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { popoverSurface = children[0]; } - return { + const state: InternalPopoverState = { open, setOpen, toggleOpen, @@ -283,42 +616,31 @@ export const usePopover = (props: PopoverProps): PopoverState => { positioning, surfaceId, shouldRenderSurface: renderSurface, + capture: onBoundaryCapture, }; + return state; }; export const usePopoverContextValues = (state: PopoverState): { popover: PopoverContextValue } => { - const { - open, - setOpen, - toggleOpen, - triggerRef, - contentRef, - arrowRef, - openOnHover, - openOnContext, - withArrow, - trapFocus, - positioning, - surfaceId, - } = state; - - return { - popover: { - open, - setOpen, - toggleOpen, - triggerRef, - contentRef, - arrowRef, - openOnHover, - openOnContext, - withArrow, - trapFocus, - positioning: { - targetRef: positioning.targetRef, - containerRef: positioning.containerRef, - }, - surfaceId, + const { positioning } = state; + + const popover: InternalPopoverContextValue = { + open: state.open, + setOpen: state.setOpen, + toggleOpen: state.toggleOpen, + triggerRef: state.triggerRef, + contentRef: state.contentRef, + arrowRef: state.arrowRef, + openOnHover: state.openOnHover, + openOnContext: state.openOnContext, + withArrow: state.withArrow, + trapFocus: state.trapFocus, + positioning: { + targetRef: positioning.targetRef, + containerRef: positioning.containerRef, }, + surfaceId: state.surfaceId, + capture: (state as InternalPopoverState).capture, }; + return { popover }; }; 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 6408bef280f57b..af6cb2dc855070 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/library/src/testing/PopoverTestFixtures.tsx b/packages/react-components/react-headless-components-preview/library/src/testing/PopoverTestFixtures.tsx new file mode 100644 index 00000000000000..ddd4505283aa88 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/testing/PopoverTestFixtures.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Portal } from '@fluentui/react-portal'; +import { Popover } from '../components/Popover/Popover'; +import { PopoverTrigger } from '../components/Popover/PopoverTrigger/PopoverTrigger'; +import { PopoverSurface } from '../components/Popover/PopoverSurface/PopoverSurface'; +import type { PopoverProps } from '../components/Popover/Popover.types'; + +export type TestOpenMode = 'uncontrolled' | 'accept' | 'reject'; + +export const useTestPopoverOpen = ( + mode: TestOpenMode, + initiallyOpen: boolean, + onOpenChange?: PopoverProps['onOpenChange'], +): Pick => { + const [open, setOpen] = React.useState(initiallyOpen); + return { + defaultOpen: mode === 'uncontrolled' ? initiallyOpen : undefined, + open: mode === 'uncontrolled' ? undefined : open, + onOpenChange: (event, data) => { + onOpenChange?.(event, data); + if (mode === 'accept' || (mode === 'reject' && data.open)) { + setOpen(data.open); + } + }, + }; +}; + +export const PopoverFocusExample = ({ + textOnly = false, + ...props +}: Partial & { textOnly?: boolean }): React.ReactElement => ( + <> + + + + + + + {textOnly ? 'Surface' : } + {props.children} + + + + +); + +export const TriggerPortalExample = ({ + mountNode, + autoFocus = false, + interactive = true, + ...props +}: Partial> & { + mountNode: HTMLElement; + autoFocus?: boolean; + interactive?: boolean; +}): React.ReactElement => ( + + +
+ Trigger + + + +
+
+ + {interactive ? : 'Surface'} + +
+); 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 21b1f44a9345b6..cf3f2121114914 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,9 @@ 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 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. + +Hover-triggered content remains open while the pointer is over the trigger or surface branch, including owned Portal content, even after keyboard focus leaves. Opening from a focused trigger-owned shadow Portal establishes observation before the first Tab exit. A Popover hosted in a same-origin iframe also detects focus leaving for a parent-page control. + +Same-origin iframe focus is observed directly. A 100 ms fallback runs only while a descendant frame holds focus to observe transitions unavailable across document boundaries. A complete inside/outside round trip within inaccessible cross-origin content between samples may not be observed. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverHoverWithPortal.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverHoverWithPortal.stories.tsx new file mode 100644 index 00000000000000..93c2764f68e28e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverHoverWithPortal.stories.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Portal } from '@fluentui/react-components'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; + +import styles from './popover.module.css'; + +export const HoverWithPortal = (): React.ReactNode => { + const [mountNode, setMountNode] = React.useState(null); + return ( + <> + + + + + +

+ Hover the portalled button, focus it, then press Tab without moving the pointer. Both panels stay visible + until the pointer leaves. +

+ {mountNode && ( + +
+ +
+
+ )} +
+
+
+ + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInFrame.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInFrame.stories.tsx new file mode 100644 index 00000000000000..8f1d1aad0f906e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInFrame.stories.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; + +import styles from './popover.module.css'; + +export const InFrame = (): React.ReactNode => { + const [doc, setDocument] = React.useState(null); + return ( + <> + +