Skip to content
Merged
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,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<typeof usePopupMenuRoot>) => void
}) {
const hostRef = React.useRef<HTMLInputElement>(null)
const root = usePopupMenuRoot({
defaultOpen: true,
getDismissExemptElements: () => (props.exempt ? [hostRef.current] : []),
})
const open = root.store.useState('open')
props.onRoot?.(root)
return (
<>
<input ref={hostRef} data-testid="host" />
<button type="button" data-testid="elsewhere">
Elsewhere
</button>
<PopupMenuProviders
store={root.store}
menuTreeResolver={root.menuTreeResolver}
focusOwnerStore={root.focusOwnerStore}
openChainStore={root.openChainStore}
disabled={root.disabled}
depth={0}
closeAll={root.closeAll}
registerSurface={root.registerSurface}
menuType="dropdown"
externalFocus
>
<Popover.Root
open={open}
modal={false}
onOpenChange={(nextOpen, details) =>
root.handleOpenChange(
nextOpen,
details.reason as Parameters<typeof root.handleOpenChange>[1],
details.event,
)
}
>
<DropdownMenu.Portal>
<DropdownMenu.Positioner>
<DropdownMenu.Popup data-testid="popup">
<DropdownMenu.Surface>
<DropdownMenu.List>
<DropdownMenu.Item>Apple</DropdownMenu.Item>
</DropdownMenu.List>
</DropdownMenu.Surface>
</DropdownMenu.Popup>
</DropdownMenu.Positioner>
</DropdownMenu.Portal>
</Popover.Root>
</PopupMenuProviders>
</>
)
}

describe('usePopupMenuRoot outside-press exemptions', () => {
it('stays open when an exempt element is pressed', async () => {
const user = userEvent.setup()
render(<ExemptRoot exempt />)
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(<ExemptRoot exempt />)
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(<ExemptRoot exempt={false} />)
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<typeof usePopupMenuRoot>
render(<ExemptRoot exempt onRoot={(r) => (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<typeof usePopupMenuRoot>
render(<ExemptRoot exempt onRoot={(r) => (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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element | null | undefined>

/**
* Whether the menu should ignore user interaction.
* Can be controlled declaratively via this prop, or imperatively via actionsRef.setDisabled().
Expand Down Expand Up @@ -152,6 +160,7 @@ export function usePopupMenuRoot(
items: itemsProp,
onHighlightChange,
closeOnOutsidePress = 'pointerdown',
getDismissExemptElements,
disabled: disabledProp = false,
defaultDisabled = false,
getResolvedId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -360,14 +379,27 @@ 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) {
focusOwnerStore.clearOwner()
openChainStore.clear()
}
},
[store, focusOwnerStore, openChainStore, disabled],
[store, focusOwnerStore, openChainStore, disabled, isExemptNode],
)

// Memoize listbox wiring config
Expand Down
Loading