import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from './context-menu'
// Base UI's popup animation toggles pointer-events while entering; disable
// user-event's safety check the same way the Select/DropdownMenu tests do.
const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 })
function BasicMenu({ size, onSelect }: { size?: 'sm' | 'md' | 'lg'; onSelect?: () => void } = {}) {
return (
Right click here
Actions
Profile
Settings
Disabled
Docs
)
}
describe('ContextMenu', () => {
it('does not render content before being opened', () => {
render()
expect(screen.queryByText('Profile')).toBeNull()
})
it('opens on right click and shows items', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
expect(await screen.findByRole('menuitem', { name: 'Profile' })).toBeInTheDocument()
})
it('fires onClick and closes on item click', async () => {
const user = setupUser()
const onSelect = vi.fn()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
await user.click(await screen.findByRole('menuitem', { name: 'Profile' }))
expect(onSelect).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(screen.queryByRole('menuitem', { name: 'Profile' })).toBeNull()
})
})
it('closes on Escape', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
await screen.findByRole('menuitem', { name: 'Profile' })
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByRole('menuitem', { name: 'Profile' })).toBeNull()
})
})
it('propagates size to items via navigationLinkVariants', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const item = await screen.findByRole('menuitem', { name: 'Profile' })
expect(item.className).toContain('text-base')
})
it('renders the popup with size-driven radius', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const item = await screen.findByRole('menuitem', { name: 'Profile' })
const popup = item.closest('[data-slot="context-menu-content"]') as HTMLElement
expect(popup).not.toBeNull()
expect(popup.className).toContain('rounded-md')
expect(popup.className).toContain('bg-background')
})
it('renders a disabled item with data-disabled', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const disabled = await screen.findByRole('menuitem', { name: 'Disabled' })
expect(disabled.getAttribute('data-disabled')).not.toBeNull()
})
it('renders a LinkItem as an anchor with href', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const link = (await screen.findByText('Docs')).closest('a') as HTMLAnchorElement
expect(link).not.toBeNull()
expect(link.getAttribute('href')).toBe('https://example.com')
})
it('navigates items with the keyboard', async () => {
const user = setupUser()
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
await screen.findByRole('menuitem', { name: 'Profile' })
await user.keyboard('{ArrowDown}')
await waitFor(() => {
const profile = screen.getByRole('menuitem', { name: 'Profile' })
expect(profile.getAttribute('data-highlighted')).not.toBeNull()
})
})
it('RadioGroup: controlled value round-trips and only one indicator is checked', async () => {
const user = setupUser()
const onValueChange = vi.fn()
function Wrapped() {
const [value, setValue] = React.useState('a')
return (
Right click
{
onValueChange(next)
setValue(next as string)
}}
>
A
B
)
}
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const itemB = await screen.findByRole('menuitemradio', { name: 'B' })
await user.click(itemB)
expect(onValueChange).toHaveBeenCalledWith('b')
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const items = await screen.findAllByRole('menuitemradio')
const checked = items.filter((el) => el.getAttribute('data-checked') !== null)
expect(checked).toHaveLength(1)
expect(checked[0]?.textContent).toBe('B')
})
it('CheckboxItem: controlled checked toggles via onCheckedChange', async () => {
const user = setupUser()
const onCheckedChange = vi.fn()
function Wrapped() {
const [checked, setChecked] = React.useState(false)
return (
Right click
{
onCheckedChange(next)
setChecked(next)
}}
>
Notifications
)
}
render()
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const item = await screen.findByRole('menuitemcheckbox', { name: 'Notifications' })
await user.click(item)
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
it('Submenu: SubTrigger renders chevron and opens on hover', async () => {
const user = setupUser()
render(
Right click
Top
More
Nested
,
)
await user.pointer({ keys: '[MouseRight]', target: screen.getByTestId('trigger') })
const subTrigger = await screen.findByRole('menuitem', { name: /More/ })
expect(subTrigger.querySelector('svg')).not.toBeNull()
await user.hover(subTrigger)
expect(await screen.findByRole('menuitem', { name: 'Nested' })).toBeInTheDocument()
})
it('has no a11y violations in closed state', async () => {
const { container } = render()
expect(await axe(container)).toHaveNoViolations()
})
})