From 31ceae19c1bfc87516ad13aaef1a044e995143a9 Mon Sep 17 00:00:00 2001 From: petdud Date: Thu, 16 Apr 2026 14:41:12 +0200 Subject: [PATCH 1/6] fix: close Popover when focus escapes outside while trapFocus is enabled --- ...-ea9e0418-25e8-45e1-8dda-ffbe8169164a.json | 7 ++ .../library/etc/react-popover.api.md | 2 +- .../src/components/Popover/Popover.test.tsx | 114 +++++++++++++++++- .../src/components/Popover/Popover.types.ts | 1 + .../src/components/Popover/usePopover.ts | 29 +++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 change/@fluentui-react-popover-ea9e0418-25e8-45e1-8dda-ffbe8169164a.json diff --git a/change/@fluentui-react-popover-ea9e0418-25e8-45e1-8dda-ffbe8169164a.json b/change/@fluentui-react-popover-ea9e0418-25e8-45e1-8dda-ffbe8169164a.json new file mode 100644 index 00000000000000..2901078e0ee164 --- /dev/null +++ b/change/@fluentui-react-popover-ea9e0418-25e8-45e1-8dda-ffbe8169164a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: close Popover when focus escapes outside while trapFocus is enabled", + "packageName": "@fluentui/react-popover", + "email": "petrduda@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-popover/library/etc/react-popover.api.md b/packages/react-components/react-popover/library/etc/react-popover.api.md index 239f1482b6e149..3a82e29fd5a739 100644 --- a/packages/react-components/react-popover/library/etc/react-popover.api.md +++ b/packages/react-components/react-popover/library/etc/react-popover.api.md @@ -33,7 +33,7 @@ export type OnOpenChangeData = { }; // @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: React_2.FC; diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx index 7a5c20e00b5086..fc91a2b40ef984 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Popover } from './Popover'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; import { usePopover_unstable } from './usePopover'; import { isConformant } from '../../testing/isConformant'; @@ -37,4 +37,116 @@ describe('Popover', () => { // Assert expect(result.current.withArrow).toBe(false); }); + + describe('close on focus outside', () => { + it('should close when trapFocus is enabled and focus moves outside', () => { + const onOpenChange = jest.fn(); + const outsideButton = document.createElement('button'); + const popoverContent = document.createElement('div'); + document.body.appendChild(outsideButton); + document.body.appendChild(popoverContent); + + const { result } = renderHook( + ({ open }) => + usePopover_unstable({ + open, + trapFocus: true, + onOpenChange, + children:
, + }), + { initialProps: { open: true } }, + ); + + // Set the contentRef to simulate mounted popover content + act(() => { + (result.current.contentRef as React.RefObject).current = popoverContent; + }); + + act(() => { + outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), { open: false }); + + document.body.removeChild(outsideButton); + document.body.removeChild(popoverContent); + }); + + it('should not close when trapFocus is not enabled and focus moves outside', () => { + const onOpenChange = jest.fn(); + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + + renderHook(() => + usePopover_unstable({ + open: true, + trapFocus: false, + onOpenChange, + children:
, + }), + ); + + act(() => { + outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + + document.body.removeChild(outsideButton); + }); + + it('should not close when popover is not open', () => { + const onOpenChange = jest.fn(); + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + + renderHook(() => + usePopover_unstable({ + open: false, + trapFocus: true, + onOpenChange, + children:
, + }), + ); + + act(() => { + outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + + document.body.removeChild(outsideButton); + }); + + it('should also close when inertTrapFocus is enabled and focus moves to a page element outside', () => { + const onOpenChange = jest.fn(); + const outsideButton = document.createElement('button'); + const popoverContent = document.createElement('div'); + document.body.appendChild(outsideButton); + document.body.appendChild(popoverContent); + + const { result } = renderHook(() => + usePopover_unstable({ + open: true, + trapFocus: true, + inertTrapFocus: true, + onOpenChange, + children:
, + }), + ); + + act(() => { + (result.current.contentRef as React.RefObject).current = popoverContent; + }); + + act(() => { + outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), { open: false }); + + document.body.removeChild(outsideButton); + document.body.removeChild(popoverContent); + }); + }); }); diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts index 251eff842d04d7..cac9e90d6d36e4 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts @@ -236,6 +236,7 @@ export type OnOpenChangeData = { open: boolean }; export type OpenPopoverEvents = | MouseEvent | TouchEvent + | FocusEvent | React.FocusEvent | React.KeyboardEvent | React.MouseEvent; diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index 265425dbad1006..06b35757b93cf6 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -168,6 +168,35 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta disabled: !open || !closeOnScroll, }); + // When trapFocus is enabled, close the popover if focus is programmatically moved outside + // (e.g. via element.focus()), which doesn't trigger click or scroll dismiss handlers. + const closeOnFocusOutCallback = useEventCallback((ev: FocusEvent) => { + const target = ev.target as HTMLElement; + const contentElement = positioningRefs.contentRef.current; + + if (!contentElement) { + return; + } + + const isOutside = + !elementContains(contentElement, target) && !elementContains(positioningRefs.triggerRef.current || null, target); + + if (isOutside) { + setOpen(ev, false); + } + }); + + React.useEffect(() => { + if (!open || !props.trapFocus) { + return; + } + + targetDocument?.addEventListener('focusin', closeOnFocusOutCallback, true); + return () => { + targetDocument?.removeEventListener('focusin', closeOnFocusOutCallback, true); + }; + }, [open, props.trapFocus, targetDocument, closeOnFocusOutCallback]); + const { findFirstFocusable } = useFocusFinders(); const activateModal = useActivateModal(); From 5fa79fd666047287ab4a03f45b93aa05f6bd8777 Mon Sep 17 00:00:00 2001 From: petdud Date: Thu, 16 Apr 2026 14:41:12 +0200 Subject: [PATCH 2/6] fix: close Popover when focus escapes outside while trapFocus is enabled --- .../src/components/Popover/Popover.cy.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx index 39ee679ee79908..2a3b2468d8d495 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx @@ -453,6 +453,69 @@ describe('Popover', () => { cy.contains('Two').should('have.focus'); }); }); + + describe('close on focus escape', () => { + it('should close when focus is programmatically moved outside', () => { + mount( + <> + + + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + cy.get('#outside').focus(); + cy.get(popoverInteractiveContentSelector).should('not.exist'); + }); + + it('should close with inertTrapFocus when focus is programmatically moved outside', () => { + mount( + <> + + + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + cy.get('#outside').focus(); + cy.get(popoverInteractiveContentSelector).should('not.exist'); + }); + + it('should not close without trapFocus when focus moves outside', () => { + mount( + <> + + + + + + This is a popover + + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('be.visible'); + cy.get('#outside').focus(); + cy.get(popoverContentSelector).should('be.visible'); + }); + }); }); describe('with Iframe', () => { From 1651a39ac1bbb230a09dd81c260c675646defbb7 Mon Sep 17 00:00:00 2001 From: Petr Duda Date: Mon, 20 Apr 2026 13:37:03 +0200 Subject: [PATCH 3/6] Update packages/react-components/react-popover/library/src/components/Popover/usePopover.ts Co-authored-by: Oleksandr Fediashov --- .../react-popover/library/src/components/Popover/usePopover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index 06b35757b93cf6..2705d3e70fe19a 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -173,13 +173,14 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta const closeOnFocusOutCallback = useEventCallback((ev: FocusEvent) => { const target = ev.target as HTMLElement; const contentElement = positioningRefs.contentRef.current; + const triggerElement = positioningRefs.triggerRef.current ?? null; if (!contentElement) { return; } const isOutside = - !elementContains(contentElement, target) && !elementContains(positioningRefs.triggerRef.current || null, target); + !elementContains(contentElement, target) && !elementContains(triggerElement, target); if (isOutside) { setOpen(ev, false); From c84b3121f3fc3cb72671c71d955d0e9439c1fe93 Mon Sep 17 00:00:00 2001 From: petdud Date: Mon, 20 Apr 2026 13:47:16 +0200 Subject: [PATCH 4/6] style: fix prettier formatting --- .../react-popover/library/src/components/Popover/usePopover.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index 2705d3e70fe19a..6c5d7f41c0ea37 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -179,8 +179,7 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta return; } - const isOutside = - !elementContains(contentElement, target) && !elementContains(triggerElement, target); + const isOutside = !elementContains(contentElement, target) && !elementContains(triggerElement, target); if (isOutside) { setOpen(ev, false); From d92fa1956d35d5e8a2afa71480a46c456cddd6c2 Mon Sep 17 00:00:00 2001 From: petdud Date: Fri, 24 Apr 2026 16:31:12 +0200 Subject: [PATCH 5/6] feat: add internal closeOnFocusOutside prop with opt-out, improve test coverage --- .../src/components/Popover/Popover.cy.tsx | 39 +++++++++++ .../src/components/Popover/Popover.test.tsx | 66 +++++++++++++++++++ .../src/components/Popover/usePopover.ts | 10 ++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx index 2a3b2468d8d495..df3e079767cf57 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx @@ -515,6 +515,45 @@ describe('Popover', () => { cy.get('#outside').focus(); cy.get(popoverContentSelector).should('be.visible'); }); + + it('should not close when closeOnFocusOutside is false', () => { + mount( + <> + + + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + cy.get('#outside').focus(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + }); + + it('should not close when focus moves to the trigger', () => { + mount( + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + cy.get(popoverTriggerSelector).focus(); + cy.get(popoverInteractiveContentSelector).should('be.visible'); + }); }); }); diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx index fc91a2b40ef984..1ea09e69cc276e 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx @@ -148,5 +148,71 @@ describe('Popover', () => { document.body.removeChild(outsideButton); document.body.removeChild(popoverContent); }); + + it('should not close when closeOnFocusOutside is false', () => { + const onOpenChange = jest.fn(); + const outsideButton = document.createElement('button'); + const popoverContent = document.createElement('div'); + document.body.appendChild(outsideButton); + document.body.appendChild(popoverContent); + + const { result } = renderHook( + ({ open }) => + usePopover_unstable({ + open, + trapFocus: true, + closeOnFocusOutside: false, + onOpenChange, + children:
, + } as any), + { initialProps: { open: true } }, + ); + + act(() => { + (result.current.contentRef as React.RefObject).current = popoverContent; + }); + + act(() => { + outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + + document.body.removeChild(outsideButton); + document.body.removeChild(popoverContent); + }); + + it('should not close when focus moves to the trigger element', () => { + const onOpenChange = jest.fn(); + const triggerButton = document.createElement('button'); + const popoverContent = document.createElement('div'); + document.body.appendChild(triggerButton); + document.body.appendChild(popoverContent); + + const { result } = renderHook( + ({ open }) => + usePopover_unstable({ + open, + trapFocus: true, + onOpenChange, + children:
, + }), + { initialProps: { open: true } }, + ); + + act(() => { + (result.current.contentRef as React.RefObject).current = popoverContent; + (result.current.triggerRef as React.RefObject).current = triggerButton; + }); + + act(() => { + triggerButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + + document.body.removeChild(triggerButton); + document.body.removeChild(popoverContent); + }); }); }); diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index 6c5d7f41c0ea37..a19e11fbc18482 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -170,8 +170,12 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta // When trapFocus is enabled, close the popover if focus is programmatically moved outside // (e.g. via element.focus()), which doesn't trigger click or scroll dismiss handlers. + // Internal `closeOnFocusOutside` prop allows consumers to opt out during gradual rollout. + const closeOnFocusOutside = + (props as PopoverBaseProps & { closeOnFocusOutside?: boolean }).closeOnFocusOutside ?? true; + const closeOnFocusOutCallback = useEventCallback((ev: FocusEvent) => { - const target = ev.target as HTMLElement; + const target = (ev.composedPath()[0] ?? ev.target) as HTMLElement; const contentElement = positioningRefs.contentRef.current; const triggerElement = positioningRefs.triggerRef.current ?? null; @@ -187,7 +191,7 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta }); React.useEffect(() => { - if (!open || !props.trapFocus) { + if (!open || !props.trapFocus || !closeOnFocusOutside) { return; } @@ -195,7 +199,7 @@ export const usePopoverBase_unstable = (props: PopoverBaseProps): PopoverBaseSta return () => { targetDocument?.removeEventListener('focusin', closeOnFocusOutCallback, true); }; - }, [open, props.trapFocus, targetDocument, closeOnFocusOutCallback]); + }, [open, props.trapFocus, closeOnFocusOutside, targetDocument, closeOnFocusOutCallback]); const { findFirstFocusable } = useFocusFinders(); const activateModal = useActivateModal(); From 9f6db5ad711944c64e64c20f6114509aded8b2d8 Mon Sep 17 00:00:00 2001 From: petdud Date: Fri, 24 Apr 2026 20:32:34 +0200 Subject: [PATCH 6/6] fix: use double assertion instead of eslint-disable for internal prop casts --- .../library/src/components/Popover/Popover.cy.tsx | 2 +- .../library/src/components/Popover/Popover.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx index df3e079767cf57..6a0d888e9c597d 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.cy.tsx @@ -520,7 +520,7 @@ describe('Popover', () => { mount( <> - + diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx index 1ea09e69cc276e..b6339053a9f9c9 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.test.tsx @@ -3,6 +3,7 @@ import { Popover } from './Popover'; import { renderHook, act } from '@testing-library/react-hooks'; import { usePopover_unstable } from './usePopover'; import { isConformant } from '../../testing/isConformant'; +import type { PopoverProps } from './Popover.types'; describe('Popover', () => { isConformant({ @@ -164,7 +165,7 @@ describe('Popover', () => { closeOnFocusOutside: false, onOpenChange, children:
, - } as any), + } as unknown as PopoverProps), { initialProps: { open: true } }, );