Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{

@github-actions github-actions Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/CalendarCompat 4 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/CalendarCompat.multiDayView - High Contrast.default.chromium.png 680 Changed
vr-tests-react-components/CalendarCompat.multiDayView - Dark Mode.default.chromium.png 555 Changed
vr-tests-react-components/CalendarCompat.multiDayView.default.chromium_1.png 481 Changed
vr-tests-react-components/CalendarCompat.multiDayView.default.chromium.png 391 Changed
vr-tests-react-components/Menu 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu.Nested Submenus Small Viewport Stacked.nested menu.chromium.png 923 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 625 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 608 Changed
vr-tests-react-components/ProgressBar converged 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 2 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 41 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - RTL.disabled input hover.chromium.png 635 Changed
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled.disabled input hover.chromium.png 677 Changed

There were 4 duplicate changes discarded. Check the build logs for more information.

"type": "patch",
"comment": "fix: close Popover on focus escape only when focus was inside the surface, so a popover whose focus never entered (e.g. an external input stays focused) is not dismissed when focus moves programmatically",
"packageName": "@fluentui/react-popover",
"email": "petrduda@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,78 @@ describe('Popover', () => {
cy.get(popoverInteractiveContentSelector).should('be.visible');
});

it('should not close when focus moves outside but was never inside the popover', () => {
const ControlledPopover = () => {
const [open, setOpen] = React.useState(false);

return (
<>
<input id="outside" aria-label="external input" />
<button id="open-programmatically" onClick={() => setOpen(true)}>
Open programmatically
</button>
<Popover
open={open}
onOpenChange={(_event, data) => setOpen(data.open)}
trapFocus
unstable_disableAutoFocus
>
<PopoverTrigger disableButtonEnhancement>
<button>Popover anchor</button>
</PopoverTrigger>
<PopoverSurface>
<button>Inside</button>
</PopoverSurface>
</Popover>
</>
);
};

mount(<ControlledPopover />);

cy.get('#outside').focus();
cy.get('#open-programmatically').click();
cy.get(popoverInteractiveContentSelector).should('be.visible');
cy.get('#outside').focus();
cy.get(popoverInteractiveContentSelector).should('be.visible');
});

it('should close when autofocus is disabled but focus moves from inside to outside', () => {
const ControlledPopover = () => {
const [open, setOpen] = React.useState(false);

return (
<>
<button id="outside">Outside</button>
<button id="open-programmatically" onClick={() => setOpen(true)}>
Open programmatically
</button>
<Popover
open={open}
onOpenChange={(_event, data) => setOpen(data.open)}
trapFocus
unstable_disableAutoFocus
>
<PopoverTrigger disableButtonEnhancement>
<button>Popover anchor</button>
</PopoverTrigger>
<PopoverSurface>
<button>Inside</button>
</PopoverSurface>
</Popover>
</>
);
};

mount(<ControlledPopover />);

cy.get('#open-programmatically').click();
cy.get(popoverInteractiveContentSelector).should('be.visible');
cy.contains('Inside').focus();
cy.get('#outside').focus();
cy.get(popoverInteractiveContentSelector).should('not.exist');
});

it('should not close when focus moves to the trigger', () => {
mount(
<Popover trapFocus>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('Popover', () => {
});

describe('close on focus outside', () => {
it('should close when trapFocus is enabled and focus moves outside', () => {
it('should close when trapFocus is enabled and focus moves from inside to outside', () => {
const onOpenChange = jest.fn();
const outsideButton = document.createElement('button');
const popoverContent = document.createElement('div');
Expand All @@ -63,6 +63,12 @@ describe('Popover', () => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = popoverContent;
});

// Focus first enters the popover content...
act(() => {
popoverContent.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
});

// ...and is then moved outside, which should dismiss the popover.
act(() => {
outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
});
Expand All @@ -73,6 +79,41 @@ describe('Popover', () => {
document.body.removeChild(popoverContent);
});

it('should not close when focus moves outside but was never inside the popover', () => {
// Focus stays on an external element the whole time and is programmatically re-focused.
// Since focus never entered the popover, this outside -> outside change must not dismiss it.
const onOpenChange = jest.fn();
const externalInput = document.createElement('input');
const popoverContent = document.createElement('div');
document.body.appendChild(externalInput);
document.body.appendChild(popoverContent);

const { result } = renderHook(
({ open }) =>
usePopover_unstable({
open,
trapFocus: true,
onOpenChange,
children: <div />,
}),
{ initialProps: { open: true } },
);

act(() => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = popoverContent;
});

// Focus lands outside the popover (and was never inside it).
act(() => {
externalInput.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
});

expect(onOpenChange).not.toHaveBeenCalled();

document.body.removeChild(externalInput);
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');
Expand Down Expand Up @@ -119,7 +160,7 @@ describe('Popover', () => {
document.body.removeChild(outsideButton);
});

it('should also close when inertTrapFocus is enabled and focus moves to a page element outside', () => {
it('should also close when inertTrapFocus is enabled and focus moves from inside to a page element outside', () => {
const onOpenChange = jest.fn();
const outsideButton = document.createElement('button');
const popoverContent = document.createElement('div');
Expand All @@ -140,6 +181,10 @@ describe('Popover', () => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = popoverContent;
});

act(() => {
popoverContent.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
});

act(() => {
outsideButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,23 +126,33 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => {
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.
// When trapFocus is enabled, dismiss the popover only on a genuine inside -> outside focus
// transition. Focus that was never inside - e.g. a consumer keeping focus on an external element
// via `unstable_disableAutoFocus` - must not dismiss it on an outside -> outside focus change.
// Internal `closeOnFocusOutside` prop allows consumers to opt out during gradual rollout.
const closeOnFocusOutside = (props as PopoverProps & { closeOnFocusOutside?: boolean }).closeOnFocusOutside ?? true;

const focusWasInsideRef = React.useRef(false);

const closeOnFocusOutCallback = useEventCallback((ev: FocusEvent) => {
const target = (ev.composedPath()[0] ?? ev.target) as HTMLElement;
const target = (ev.composedPath()[0] ?? ev.target) as HTMLElement | null;
const contentElement = positioningRefs.contentRef.current;
const triggerElement = positioningRefs.triggerRef.current ?? null;

if (!contentElement) {
return;
}

const isOutside = !elementContains(contentElement, target) && !elementContains(triggerElement, target);
const isInside = elementContains(contentElement, target) || elementContains(triggerElement, target);

if (isInside) {
focusWasInsideRef.current = true;
return;
}

if (isOutside) {
// Only dismiss if focus previously lived inside the popover.
if (focusWasInsideRef.current) {
focusWasInsideRef.current = false;
setOpen(ev, false);
}
});
Expand All @@ -152,6 +162,7 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => {
return;
}

focusWasInsideRef.current = false;
targetDocument?.addEventListener('focusin', closeOnFocusOutCallback, true);
return () => {
targetDocument?.removeEventListener('focusin', closeOnFocusOutCallback, true);
Expand Down
Loading