diff --git a/packages/react/src/dialog/root/DialogRoot.detached-triggers.test.tsx b/packages/react/src/dialog/root/DialogRoot.detached-triggers.test.tsx
index 1b39a170bc2..9244a1ca497 100644
--- a/packages/react/src/dialog/root/DialogRoot.detached-triggers.test.tsx
+++ b/packages/react/src/dialog/root/DialogRoot.detached-triggers.test.tsx
@@ -183,7 +183,7 @@ describe('', () => {
return (
-
+
{({ payload }: NumberPayload) => (
@@ -196,6 +196,7 @@ describe('', () => {
{payload}
+ Close
@@ -223,6 +224,13 @@ describe('', () => {
await waitFor(() => {
expect(screen.getByTestId('content').textContent).toBe('2');
});
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('content')).toBe(null);
+ });
+ expect(openButton).toHaveFocus();
});
it('keeps the payload reactive', async () => {
diff --git a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx
index 370c38b5f9e..a4225854262 100644
--- a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx
+++ b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx
@@ -730,9 +730,12 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
}
const doc = ownerDocument(floatingFocusElement);
- const previouslyFocusedElement = activeElement(doc);
+ const elementFocusedBeforeOpen = activeElement(doc);
+ // Only nullish interaction types represent programmatic opens. The empty
+ // string default is intentionally not treated as programmatic.
+ const preferPreviousFocus = openInteractionTypeRef.current == null;
- addPreviouslyFocusedElement(previouslyFocusedElement);
+ addPreviouslyFocusedElement(elementFocusedBeforeOpen);
// Dismissing via outside press should always ignore `returnFocus` to
// prevent unwanted scrolling.
@@ -793,17 +796,25 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
resolvedReturnFocusValue = true;
}
- if (typeof resolvedReturnFocusValue === 'boolean') {
- if (domReference?.isConnected) {
- return domReference;
- }
+ const referenceReturnElement = domReference?.isConnected ? domReference : null;
+ const previousReturnElement =
+ elementFocusedBeforeOpen?.isConnected && getNodeName(elementFocusedBeforeOpen) !== 'body'
+ ? elementFocusedBeforeOpen
+ : null;
+
+ let defaultReturnElement = preferPreviousFocus
+ ? previousReturnElement || referenceReturnElement
+ : referenceReturnElement || previousReturnElement;
- return getPreviouslyFocusedElement() || null;
+ if (!defaultReturnElement) {
+ defaultReturnElement = getPreviouslyFocusedElement() || null;
}
- const fallback = domReference?.isConnected ? domReference : getPreviouslyFocusedElement();
+ if (typeof resolvedReturnFocusValue === 'boolean') {
+ return defaultReturnElement;
+ }
- return resolveRef(resolvedReturnFocusValue) || fallback || null;
+ return resolveRef(resolvedReturnFocusValue) || defaultReturnElement || null;
}
return () => {
@@ -850,6 +861,7 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
floating,
floatingFocusElement,
returnFocusRef,
+ openInteractionTypeRef,
events,
tree,
domReference,
diff --git a/packages/react/src/menu/popup/MenuPopup.tsx b/packages/react/src/menu/popup/MenuPopup.tsx
index 0bf5ff6c3e8..4097ea7fc61 100644
--- a/packages/react/src/menu/popup/MenuPopup.tsx
+++ b/packages/react/src/menu/popup/MenuPopup.tsx
@@ -55,6 +55,7 @@ export const MenuPopup = React.forwardRef(function MenuPopup(
const activeTriggerElement = store.useState('activeTriggerElement');
const hoverEnabled = store.useState('hoverEnabled');
const disabled = store.useState('disabled');
+ const openMethod = store.useState('openMethod');
const isContextMenu = parent.type === 'context-menu';
@@ -134,6 +135,7 @@ export const MenuPopup = React.forwardRef(function MenuPopup(
return (
', () => {
expect(screen.queryByTestId('level-3')).toBe(null);
});
});
+
+ it.skipIf(isJSDOM)(
+ 'returns focus to submenu triggers when closing nested menus',
+ async () => {
+ const { user } = await render();
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(trigger);
+
+ await screen.findByTestId('menu');
+
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+
+ const submenuTrigger = await screen.findByTestId('submenu-trigger');
+ await waitFor(() => {
+ expect(submenuTrigger).toHaveFocus();
+ });
+
+ await user.keyboard('[ArrowRight]');
+
+ const nestedSubmenuTrigger = await screen.findByTestId('nested-submenu-trigger');
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+
+ await waitFor(() => {
+ expect(nestedSubmenuTrigger).toHaveFocus();
+ });
+
+ await user.keyboard('[ArrowRight]');
+ await screen.findByTestId('nested-submenu');
+
+ await user.keyboard('[ArrowLeft]');
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('nested-submenu')).toBe(null);
+ });
+ expect(nestedSubmenuTrigger).toHaveFocus();
+
+ await user.keyboard('[ArrowLeft]');
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('submenu')).toBe(null);
+ });
+ expect(submenuTrigger).toHaveFocus();
+ },
+ );
+ });
+
+ describe('controlled open', () => {
+ it('returns focus to the opener when a menu is opened programmatically', async () => {
+ function Test() {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+
+
+
+
+ Close menu
+
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const opener = screen.getByRole('button', { name: 'Open menu programmatically' });
+ await user.click(opener);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.toBe(null);
+ });
+
+ await user.click(screen.getByRole('menuitem', { name: 'Close menu' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).toBe(null);
+ });
+ expect(opener).toHaveFocus();
+ });
});
describe('nested popups', () => {
diff --git a/packages/react/src/popover/root/PopoverRoot.detached-triggers.test.tsx b/packages/react/src/popover/root/PopoverRoot.detached-triggers.test.tsx
index 104bc438045..2b70090ca26 100644
--- a/packages/react/src/popover/root/PopoverRoot.detached-triggers.test.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.detached-triggers.test.tsx
@@ -190,6 +190,7 @@ describe('', () => {
{payload as number}
+ Close
@@ -212,7 +213,6 @@ describe('', () => {
>
Open Trigger 2
-
);
}
@@ -220,10 +220,137 @@ describe('', () => {
const { user } = await render();
await user.click(screen.getByRole('button', { name: 'Open Trigger 1' }));
expect(screen.getByTestId('content').textContent).toBe('1');
- await user.click(screen.getByRole('button', { name: 'Open Trigger 2' }));
+ const openTrigger2Button = screen.getByRole('button', { name: 'Open Trigger 2' });
+ await user.click(openTrigger2Button);
expect(screen.getByTestId('content').textContent).toBe('2');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByTestId('content')).toBe(null);
+ expect(openTrigger2Button).toHaveFocus();
+ });
+
+ it('returns focus to the active trigger when opening programmatically from body focus', async () => {
+ function Test() {
+ const [open, setOpen] = React.useState(false);
+ const [activeTrigger, setActiveTrigger] = React.useState(null);
+
+ return (
+
+ {
+ setActiveTrigger(details.trigger?.id ?? null);
+ setOpen(nextOpen);
+ }}
+ >
+
+ Trigger 1
+
+
+ Trigger 2
+
+
+
+
+
+ Content
+ Close
+
+
+
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const trigger1 = screen.getByRole('button', { name: 'Trigger 1' });
+ const trigger2 = screen.getByRole('button', { name: 'Trigger 2' });
+
+ await user.click(trigger1);
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+ await waitFor(() => {
+ expect(trigger1).toHaveFocus();
+ });
+
+ trigger1.blur();
+ expect(document.body).toHaveFocus();
+
+ await user.click(screen.getByRole('button', { name: 'Open Trigger 2 without focus' }));
+ await waitFor(() => {
+ expect(screen.getByTestId('content')).toBeVisible();
+ });
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+ await waitFor(() => {
+ expect(trigger2).toHaveFocus();
+ });
+ });
+
+ it('returns focus to the previous element when the trigger unmounts while open', async () => {
+ function Test() {
+ const [open, setOpen] = React.useState(false);
+ const [showTrigger, setShowTrigger] = React.useState(true);
+
+ return (
+
+
+
+ {
+ if (nextOpen) {
+ setShowTrigger(false);
+ }
+ setOpen(nextOpen);
+ }}
+ >
+ {showTrigger && (
+ event.preventDefault()}>
+ Disappearing trigger
+
+ )}
+
+
+
+
+ Content
+ Close
+
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const fallback = screen.getByRole('button', { name: 'Focus fallback' });
+ await user.click(fallback);
+ expect(fallback).toHaveFocus();
+
+ await user.click(screen.getByRole('button', { name: 'Disappearing trigger' }));
+ await waitFor(() => {
+ expect(screen.getByTestId('content')).toBeVisible();
+ });
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+ await waitFor(() => {
+ expect(screen.queryByTestId('content')).toBe(null);
+ });
+ expect(fallback).toHaveFocus();
});
it('allows setting an initially open popover', async () => {
diff --git a/packages/react/src/popover/root/PopoverRoot.test.tsx b/packages/react/src/popover/root/PopoverRoot.test.tsx
index 4822c6fc9cf..f23d7e33479 100644
--- a/packages/react/src/popover/root/PopoverRoot.test.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.test.tsx
@@ -1689,6 +1689,77 @@ describe('', () => {
});
describe('nested popup interactions', () => {
+ it('returns focus through nested programmatic popovers in close order', async () => {
+ function Test() {
+ const [childOpen, setChildOpen] = React.useState(false);
+
+ return (
+
+ Parent trigger
+
+
+
+
+
+
+ Child reference
+
+
+
+ Close child
+
+
+
+
+
+ Close parent
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const parentTrigger = screen.getByRole('button', { name: 'Parent trigger' });
+ await user.click(parentTrigger);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('parent-popup')).not.toBe(null);
+ });
+
+ const childOpener = screen.getByRole('button', {
+ name: 'Open child programmatically',
+ });
+ await user.click(childOpener);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('child-popup')).not.toBe(null);
+ });
+
+ await user.click(screen.getByRole('button', { name: 'Close child' }));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('child-popup')).toBe(null);
+ });
+ expect(childOpener).toHaveFocus();
+ expect(screen.queryByTestId('parent-popup')).not.toBe(null);
+
+ await user.click(screen.getByRole('button', { name: 'Close parent' }));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('parent-popup')).toBe(null);
+ });
+ expect(parentTrigger).toHaveFocus();
+ });
+
it('keeps the parent popover open when press starts in nested popover and ends outside', async () => {
function Test() {
return (
diff --git a/packages/react/src/select/popup/SelectPopup.tsx b/packages/react/src/select/popup/SelectPopup.tsx
index 8f3a78d3323..4eec4096350 100644
--- a/packages/react/src/select/popup/SelectPopup.tsx
+++ b/packages/react/src/select/popup/SelectPopup.tsx
@@ -83,6 +83,7 @@ export const SelectPopup = React.forwardRef(function SelectPopup(
const id = useStore(store, selectors.id);
const open = useStore(store, selectors.open);
+ const openMethod = useStore(store, selectors.openMethod);
const mounted = useStore(store, selectors.mounted);
const popupProps = useStore(store, selectors.popupProps);
const transitionStatus = useStore(store, selectors.transitionStatus);
@@ -519,6 +520,7 @@ export const SelectPopup = React.forwardRef(function SelectPopup(
context={floatingRootContext}
modal={false}
disabled={!mounted}
+ openInteractionType={openMethod}
returnFocus={finalFocus}
restoreFocus
>
diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx
index f3d99ee2c3b..5243b7ff913 100644
--- a/packages/react/src/select/root/SelectRoot.test.tsx
+++ b/packages/react/src/select/root/SelectRoot.test.tsx
@@ -1614,6 +1614,59 @@ describe('', () => {
expect(screen.queryByTestId('popover-popup')).not.toBe(null);
});
+ it('returns focus to the opener when a select is opened programmatically inside a popover', async () => {
+ function Test() {
+ const [selectOpen, setSelectOpen] = React.useState(false);
+
+ return (
+
+ Open popover
+
+
+
+
+
+
+
+
+
+
+
+ One
+ Two
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const selectOpener = screen.getByRole('button', {
+ name: 'Open select programmatically',
+ });
+ await user.click(selectOpener);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox')).not.toBe(null);
+ });
+
+ await user.click(screen.getByRole('option', { name: 'Two' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox')).toBe(null);
+ });
+ expect(selectOpener).toHaveFocus();
+ expect(screen.queryByTestId('popover-popup')).not.toBe(null);
+ });
+
it('does not consume the next outside press after a native drag from a modal select trigger outside all popups', async () => {
ignoreActWarnings();
diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx
index 6056b22c44a..bbff8c13db6 100644
--- a/packages/react/src/select/root/SelectRoot.tsx
+++ b/packages/react/src/select/root/SelectRoot.tsx
@@ -166,7 +166,7 @@ export function SelectRoot(
const positionerElement = useStore(store, selectors.positionerElement);
const previousOpenMethod = usePreviousValue(openMethod);
- const renderedOpenMethod = openMethod ?? previousOpenMethod;
+ const renderedOpenMethod = openMethod ?? previousOpenMethod ?? null;
const serializedValue = React.useMemo(() => {
if (multiple && Array.isArray(value) && value.length === 0) {