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 ( + + + + Menu trigger + + + + 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) {