diff --git a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.outside-press.test.tsx b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.outside-press.test.tsx new file mode 100644 index 00000000..071d4721 --- /dev/null +++ b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.outside-press.test.tsx @@ -0,0 +1,141 @@ +import { Popover } from '@base-ui/react/popover' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { DropdownMenu } from '../../../dropdown-menu/index.js' +import { PopupMenuProviders } from '../components/providers.js' +import { usePopupMenuRoot } from './use-popup-menu-root.js' + +function ExemptRoot(props: { + exempt: boolean + onRoot?: (root: ReturnType) => void +}) { + const hostRef = React.useRef(null) + const root = usePopupMenuRoot({ + defaultOpen: true, + getDismissExemptElements: () => (props.exempt ? [hostRef.current] : []), + }) + const open = root.store.useState('open') + props.onRoot?.(root) + return ( + <> + + + + + root.handleOpenChange( + nextOpen, + details.reason as Parameters[1], + details.event, + ) + } + > + + + + + + Apple + + + + + + + + + ) +} + +describe('usePopupMenuRoot outside-press exemptions', () => { + it('stays open when an exempt element is pressed', async () => { + const user = userEvent.setup() + render() + await screen.findByTestId('popup') + + await user.click(screen.getByTestId('host')) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(screen.getByTestId('popup')).toBeInTheDocument() + }) + + it('still closes when something else outside is pressed', async () => { + const user = userEvent.setup() + render() + await screen.findByTestId('popup') + + await user.click(screen.getByTestId('elsewhere')) + + await waitFor(() => + expect(screen.queryByTestId('popup')).not.toBeInTheDocument(), + ) + }) + + it('closes on a press on the element when it is not exempt', async () => { + render() + await screen.findByTestId('popup') + + fireEvent.pointerDown(screen.getByTestId('host')) + + await waitFor(() => + expect(screen.queryByTestId('popup')).not.toBeInTheDocument(), + ) + }) + + describe('focus-out', () => { + function focusOut(target: Element, relatedTarget: Element | null) { + const event = new FocusEvent('focusout', { relatedTarget }) + Object.defineProperty(event, 'target', { value: target }) + return event + } + + it('stays open when focus moves to an exempt element', async () => { + let root!: ReturnType + render( (root = r)} />) + const popup = await screen.findByTestId('popup') + + act(() => { + root.handleOpenChange( + false, + 'focus-out', + focusOut(popup, screen.getByTestId('host')), + ) + }) + + expect(root.store.state.open).toBe(true) + }) + + it('closes when focus leaves an exempt element for somewhere else', async () => { + let root!: ReturnType + render( (root = r)} />) + await screen.findByTestId('popup') + + act(() => { + root.handleOpenChange( + false, + 'focus-out', + focusOut(screen.getByTestId('host'), screen.getByTestId('elsewhere')), + ) + }) + + expect(root.store.state.open).toBe(false) + }) + }) +}) diff --git a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts index 78c6cbf6..e3859d37 100644 --- a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts +++ b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts @@ -74,6 +74,14 @@ export interface UsePopupMenuRootParams { */ closeOnOutsidePress?: 'click' | 'pointerdown' + /** + * Elements outside the menu that don't dismiss it (e.g. a suggestion menu's + * host input): pressing one, or moving focus from the menu to one, is not an + * outside press or a focus-out. Read when the event happens, so the returned + * elements may change over time. + */ + getDismissExemptElements?: () => ReadonlyArray + /** * Whether the menu should ignore user interaction. * Can be controlled declaratively via this prop, or imperatively via actionsRef.setDisabled(). @@ -152,6 +160,7 @@ export function usePopupMenuRoot( items: itemsProp, onHighlightChange, closeOnOutsidePress = 'pointerdown', + getDismissExemptElements, disabled: disabledProp = false, defaultDisabled = false, getResolvedId, @@ -197,6 +206,14 @@ export function usePopupMenuRoot( ensureInputModalityTracking() }, []) + const getExemptElementsRef = React.useRef(getDismissExemptElements) + getExemptElementsRef.current = getDismissExemptElements + const isExemptNode = React.useCallback((node: unknown): boolean => { + if (!(node instanceof Node)) return false + const elements = getExemptElementsRef.current?.() ?? [] + return elements.some((element) => element?.contains(node) ?? false) + }, []) + // Track outside pointer events to distinguish outside-press from focus-out // When a pointerdown happens outside the menu, we store it so that if a // focus-out close happens immediately after, we can treat it as outside-press @@ -322,6 +339,8 @@ export function usePopupMenuRoot( const handlePointerDown = (event: PointerEvent) => { const target = event.target as Element | null if (!target) return + // Presses on an exempt element (e.g. a host input) count as inside. + if (isExemptNode(target)) return // Check if the pointerdown is inside any part of the menu tree or its triggers // base-ui sets data-open on popups and data-popup-open on triggers @@ -344,7 +363,7 @@ export function usePopupMenuRoot( document.removeEventListener('pointerdown', handlePointerDown, true) outsidePointerEventRef.current = null } - }, [isOpen, closeOnOutsidePress, disabled]) + }, [isOpen, closeOnOutsidePress, disabled, isExemptNode]) // Handle open state change const handleOpenChange = React.useCallback( @@ -360,6 +379,19 @@ export function usePopupMenuRoot( return } + // A press on an exempt element, or focus moving to one, doesn't dismiss. + // Covers Base UI's own dismissal, which runs alongside our listener. + // For focus-out only the element gaining focus (`relatedTarget`) counts: + // `target` is the element losing it. + if ( + !newOpen && + ((reason === REASONS.outsidePress && isExemptNode(event?.target)) || + (reason === REASONS.focusOut && + isExemptNode((event as FocusEvent | undefined)?.relatedTarget))) + ) { + return + } + store.setOpen(newOpen, reason, event) // Clear focus ownership and open chain when menu closes if (!newOpen) { @@ -367,7 +399,7 @@ export function usePopupMenuRoot( openChainStore.clear() } }, - [store, focusOwnerStore, openChainStore, disabled], + [store, focusOwnerStore, openChainStore, disabled, isExemptNode], ) // Memoize listbox wiring config