import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import { axe } from 'vitest-axe' import { NavigationMenu, NavigationMenuContent, NavigationMenuIcon, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, } from './navigation-menu' const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }) interface BasicNavProps { variant?: 'pill' | 'line' size?: 'sm' | 'md' | 'lg' icon?: 'chevron' | 'caret' | 'plus' | false orientation?: 'horizontal' | 'vertical' backdrop?: boolean viewport?: boolean morph?: boolean } function BasicNav({ variant, size, icon, orientation, backdrop, viewport, morph }: BasicNavProps = {}) { return ( Products Product One Product Two Company About ) } describe('NavigationMenu', () => { it('renders with the navigation-menu data-slot and horizontal orientation by default', () => { const { container } = render() const root = container.querySelector('[data-slot="navigation-menu"]') as HTMLElement expect(root).not.toBeNull() expect(root.getAttribute('data-orientation')).toBe('horizontal') }) it('applies the pill gap to the list by default', () => { const { container } = render() const list = container.querySelector('[data-slot="navigation-menu-list"]') as HTMLElement expect(list.className).toContain('gap-0.5') }) it('applies the line gap to the list when variant is line', () => { const { container } = render() const list = container.querySelector('[data-slot="navigation-menu-list"]') as HTMLElement expect(list.className).toContain('gap-7') }) it('stacks the list vertically when orientation is vertical', () => { const { container } = render() const list = container.querySelector('[data-slot="navigation-menu-list"]') as HTMLElement expect(list.className).toContain('flex-col') }) it('styles triggers with the pill variant by default', () => { const { container } = render() const trigger = container.querySelector('[data-slot="navigation-menu-trigger"]') as HTMLElement expect(trigger.className).toContain('before:bg-background-muted') }) it('styles triggers with the line variant when requested', () => { const { container } = render() const trigger = container.querySelector('[data-slot="navigation-menu-trigger"]') as HTMLElement expect(trigger.className).toContain('after:bg-no-repeat') }) it('mirrors the root orientation onto each trigger', () => { const { container } = render() const trigger = container.querySelector('[data-slot="navigation-menu-trigger"]') as HTMLElement expect(trigger.getAttribute('data-orientation')).toBe('vertical') }) it('propagates size to the trigger text class', () => { const { container } = render() const trigger = container.querySelector('[data-slot="navigation-menu-trigger"]') as HTMLElement expect(trigger.className).toContain('text-base') }) it('renders a chevron icon by default and sizes it by the root size', () => { const { container } = render() const icon = container.querySelector('[data-slot="navigation-menu-icon"]') as HTMLElement expect(icon).not.toBeNull() const svg = icon.querySelector('svg') as SVGElement expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') expect(svg.getAttribute('class') ?? '').toContain('size-4') }) it('renders a caret icon when icon="caret"', () => { const { container } = render() const svg = container.querySelector('[data-slot="navigation-menu-icon"] svg') as SVGElement expect(svg.getAttribute('viewBox')).toBe('0 0 24 24') }) it('renders the plus icon with two rects (so the morph animation works) when icon="plus"', () => { const { container } = render() const svg = container.querySelector('[data-slot="navigation-menu-icon"] svg') as SVGElement const rects = svg.querySelectorAll('rect') expect(rects).toHaveLength(2) // The plus morphs into an ✕ when open: the svg rotates and the vertical bar spins. expect(svg.getAttribute('class') ?? '').toContain('group-data-popup-open/navigation-menu-icon:rotate-180') expect(rects[1]?.getAttribute('class') ?? '').toContain('group-data-popup-open/navigation-menu-icon:rotate-90') }) it('renders nothing for the icon when icon={false} at the root', () => { const { container } = render() expect(container.querySelector('[data-slot="navigation-menu-icon"]')).toBeNull() }) it('per-icon override beats the root', () => { const { container } = render( Solo X , ) expect(container.querySelector('[data-slot="navigation-menu-icon"]')).toBeNull() }) it('applies the smaller icon size class when size="sm"', () => { const { container } = render() const svg = container.querySelector('[data-slot="navigation-menu-icon"] svg') as SVGElement expect(svg.getAttribute('class') ?? '').toContain('size-3.5') }) it('opens the menu on trigger click and shows the link inside the popup', async () => { const user = setupUser() render() await user.click(screen.getByRole('button', { name: /Products/ })) expect(await screen.findByRole('link', { name: 'Product One' })).toBeInTheDocument() }) it('closes the menu on Escape', async () => { const user = setupUser() render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) await user.keyboard('{Escape}') await waitFor(() => { expect(screen.queryByRole('link', { name: 'Product One' })).toBeNull() }) }) it('moves focus between triggers with the arrow keys', async () => { const user = setupUser() render() const products = screen.getByRole('button', { name: /Products/ }) products.focus() expect(products).toHaveFocus() await user.keyboard('{ArrowRight}') expect(screen.getByRole('button', { name: /Company/ })).toHaveFocus() }) it('renders the backdrop when backdrop={true} after opening', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) expect(baseElement.querySelector('[data-slot="navigation-menu-backdrop"]')).not.toBeNull() }) it('applies the popup size CSS vars by default so the popup morphs', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) const popup = baseElement.querySelector('[data-slot="navigation-menu-popup"]') as HTMLElement expect(popup.className).toContain('w-(--popup-width)') expect(popup.className).toContain('h-(--popup-height)') }) it('drops the size CSS vars and size transition when morph={false}', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) const popup = baseElement.querySelector('[data-slot="navigation-menu-popup"]') as HTMLElement expect(popup.className).not.toContain('w-(--popup-width)') expect(popup.className).not.toContain('h-(--popup-height)') const positioner = baseElement.querySelector('[data-slot="navigation-menu-positioner"]') as HTMLElement expect(positioner.className).not.toContain('w-(--positioner-width)') }) it('omits the backdrop by default', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) expect(baseElement.querySelector('[data-slot="navigation-menu-backdrop"]')).toBeNull() }) it('skips the auto-rendered viewport when viewport={false}', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) // No popup means no link from the active content appears. await new Promise((resolve) => setTimeout(resolve, 100)) expect(baseElement.querySelector('[data-slot="navigation-menu-popup"]')).toBeNull() expect(screen.queryByRole('link', { name: 'Product One' })).toBeNull() }) it('opens the popup to inline-end when orientation is vertical', async () => { const user = setupUser() const { baseElement } = render() await user.click(screen.getByRole('button', { name: /Products/ })) await screen.findByRole('link', { name: 'Product One' }) const positioner = baseElement.querySelector('[data-slot="navigation-menu-popup"]')?.parentElement as HTMLElement // Base UI resolves logical sides; data-side reports the physical side. In LTR vertical, inline-end == right. expect(['right', 'inline-end']).toContain(positioner.getAttribute('data-side')) }) it('has no a11y violations in the closed state', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() }) })