.
expect(items.every((i) => i.tagName === 'BUTTON')).toBe(true)
})
it('the trigger says whether its menu is open', async () => {
const user = userEvent.setup()
render(
)
const trigger = screen.getByRole('button', { name: 'Trigger' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
await user.click(trigger)
await screen.findByRole('menu')
expect(trigger.getAttribute('aria-expanded')).toBe('true')
})
it('portals: the menu is not inside the element that rendered it', async () => {
const user = userEvent.setup()
const { container } = render(
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
const menu = await screen.findByRole('menu')
expect(container.contains(menu)).toBe(false)
})
it('closes on Escape and gives focus back to the trigger', async () => {
const user = userEvent.setup()
render(
)
const trigger = screen.getByRole('button', { name: 'Trigger' })
await user.click(trigger)
await screen.findByRole('menu')
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
expect(document.activeElement).toBe(trigger)
})
it('closes on a press outside', async () => {
const user = userEvent.setup()
render(
<>
Elsewhere
>,
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
await screen.findByRole('menu')
await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
})
/**
* The trigger is the menu's own, so a press on it toggles. This used to be a
* trap: the press dismissed the menu and the caller's `onClick` reopened it
* in the same gesture, and the workaround was `onMouseDown` with
* `stopPropagation`. There is no caller `onClick` any more.
*/
it('a second press on the trigger closes it', async () => {
const user = userEvent.setup()
render(
)
const trigger = screen.getByRole('button', { name: 'Trigger' })
await user.click(trigger)
await screen.findByRole('menu')
await user.click(trigger)
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
})
it('reports opening and closing to a caller that asks', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
await screen.findByRole('menu')
expect(onOpenChange).toHaveBeenCalledWith(true)
await user.keyboard('{Escape}')
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('choosing a row runs its handler and closes the menu', async () => {
const user = userEvent.setup()
const onPick = vi.fn()
render(
Trigger}>
,
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
await user.click(await screen.findByRole('menuitem', { name: 'Rename' }))
expect(onPick).toHaveBeenCalledTimes(1)
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
})
it('a section is a labelled group, so its rows are announced together', async () => {
const user = userEvent.setup()
render(
Trigger}>
{}} />
{}} />
,
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
const group = await screen.findByRole('group')
expect(group.textContent).toContain('Section')
expect(group.querySelectorAll('[role="menuitem"]')).toHaveLength(2)
})
/**
* The Keys table, pinned. Every row of it is Base UI's, and none of it
* existed before stage 4 — which is exactly why it is tested here: a
* behaviour nobody wrote is a behaviour nobody notices losing.
*
* `data-highlighted` is the assertion because it is what the row's fill
* keys off, so a test that passes and a menu that looks wrong cannot come
* apart.
*/
const highlighted = () => screen.getAllByRole('menuitem').find((i) => i.hasAttribute('data-highlighted'))?.textContent
it('opens from the keyboard with the first row highlighted', async () => {
const user = userEvent.setup()
render(
)
screen.getByRole('button', { name: 'Trigger' }).focus()
await user.keyboard('{Enter}')
await screen.findByRole('menu')
expect(highlighted()).toBe('Rename')
})
it('the arrow keys walk the rows and wrap at both ends', async () => {
const user = userEvent.setup()
render(
)
screen.getByRole('button', { name: 'Trigger' }).focus()
await user.keyboard('{Enter}')
await screen.findByRole('menu')
await user.keyboard('{ArrowDown}')
expect(highlighted()).toBe('Duplicate')
await user.keyboard('{ArrowDown}')
expect(highlighted()).toBe('Delete')
await user.keyboard('{ArrowDown}')
expect(highlighted()).toBe('Rename')
await user.keyboard('{ArrowUp}')
expect(highlighted()).toBe('Delete')
})
it('Home and End jump to the ends', async () => {
const user = userEvent.setup()
render(
)
screen.getByRole('button', { name: 'Trigger' }).focus()
await user.keyboard('{Enter}')
await screen.findByRole('menu')
await user.keyboard('{End}')
expect(highlighted()).toBe('Delete')
await user.keyboard('{Home}')
expect(highlighted()).toBe('Rename')
})
it('typing a row’s first letters jumps to it', async () => {
const user = userEvent.setup()
render(
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
await screen.findByRole('menu')
await user.keyboard('de')
expect(highlighted()).toBe('Delete')
})
it('Enter on the highlighted row activates it and closes the menu', async () => {
const user = userEvent.setup()
const onPick = vi.fn()
render(
Trigger}>
{}} />
,
)
screen.getByRole('button', { name: 'Trigger' }).focus()
await user.keyboard('{Enter}')
await screen.findByRole('menu')
await user.keyboard('{Enter}')
expect(onPick).toHaveBeenCalledTimes(1)
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
})
/**
* → opens the submenu **and takes the highlight into it**; the parent row
* gives it up. The page said the highlight stayed on the row and ↓ stepped
* in, which was written from Base UI's documentation rather than from a
* measurement, and is wrong in a browser and here (2026-09-08).
*/
it('→ opens a submenu onto its first row, and ← comes back', async () => {
const user = userEvent.setup()
render(
Trigger}>
{}} />
{}} />
{}} />
,
)
screen.getByRole('button', { name: 'Trigger' }).focus()
await user.keyboard('{Enter}')
await screen.findByRole('menu')
await user.keyboard('{ArrowDown}')
expect(highlighted()).toBe('Move to…')
await user.keyboard('{ArrowRight}')
expect(await screen.findByRole('menuitem', { name: 'Item one' })).toBeTruthy()
expect(highlighted()).toBe('Item one')
await user.keyboard('{ArrowLeft}')
await waitFor(() => expect(screen.queryByRole('menuitem', { name: 'Item one' })).toBeNull())
expect(highlighted()).toBe('Move to…')
})
it('a submenu row says it opens one', async () => {
const user = userEvent.setup()
render(
Trigger}>
{}} />
{}} />
,
)
await user.click(screen.getByRole('button', { name: 'Trigger' }))
await screen.findByRole('menu')
const sub = screen.getByRole('menuitem', { name: 'Move to…' })
expect(sub.getAttribute('aria-haspopup')).toBe('menu')
expect(sub.getAttribute('aria-expanded')).toBe('false')
expect(sub.tagName).toBe('BUTTON')
})
})
/**
* The rows outside a menu. Peek's `@`, `/` and `[` pickers and its compose
* menu draw the surface without the behaviour, because the editor's suggestion
* plugin already owns the keyboard — so `MenuItem` and `MenuSection` have to
* work with no `Menu` above them.
*/
describe('MenuSeparator', () => {
// UIG-14: the line between groups brings its own 4px above and below; a plain
// Divider left that to each caller, and Peek's main menus had none.
it('is a separator with its own room, the width of the rows', async () => {
render(
More}>
,
)
const line = await screen.findByRole('separator')
const classes = (line.getAttribute('class') ?? '').split(' ')
expect(classes).toContain('my-1')
expect(classes).toContain('mx-0')
})
})
describe('rows on a bare MenuPanel', () => {
/**
* It is a button, and it does NOT claim to be a menu item. ARIA requires a
* `menuitem` to sit inside a `menu` or a `menubar`; `MenuPanel` is a `
`
* with no role, so the role used to make an orphan in all four of the Peek
* files that draw rows this way.
*/
it('MenuItem is a plain button outside a menu, claiming no menu role', () => {
render(
{}} />
,
)
const item = screen.getByRole('button', { name: /Heading/ })
expect(item.tagName).toBe('BUTTON')
expect(item.getAttribute('role')).toBeNull()
expect(screen.queryByRole('menuitem')).toBeNull()
})
it('a default row is never shorter than 36px, and a tall one never shorter than 40', () => {
render(
{}} />
{}} />
,
)
// jsdom computes no layout, so the floor class is what it can assert;
// the story beside it is what shows the pixels.
expect(screen.getByRole('button', { name: 'Default' }).className).toContain('min-h-9')
expect(screen.getByRole('button', { name: 'Tall' }).className).toContain('min-h-10')
})
it('its onClick still runs', async () => {
const user = userEvent.setup()
const onPick = vi.fn()
render(
,
)
await user.click(screen.getByRole('button', { name: 'Heading' }))
expect(onPick).toHaveBeenCalledTimes(1)
})
it('MenuSection draws its heading and its rows, and is not a group', () => {
render(
{}} />
,
)
expect(screen.getByText('Section')).toBeTruthy()
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.queryByRole('group')).toBeNull()
})
})
/** As `Popover`: the padding is the content's, and a caller sets it there
* (PLAN Finding 60 — Peek's Later menu asked for `p-1` on `className` and got
* 12px from 0.12.6 on). */
describe('Menu, padding', () => {
it('takes a caller’s padding on contentClassName, instead of the 8px', async () => {
render(
Open} contentClassName="p-1">
{}} />
,
)
await userEvent.click(screen.getByRole('button', { name: 'Open' }))
const row = await screen.findByRole('menuitem', { name: 'Item one' })
// The scrolling content is the box the rows sit in directly.
const content = row.parentElement as HTMLElement
const classes = content.className.split(/\s+/)
expect(classes).toContain('p-1')
expect(classes).not.toContain('p-2')
})
})