// @vitest-environment jsdom /** * What the Popover page claims, pinned — including the two things that * distinguish it from a `Menu`, which is the whole reason it exists: its * contents are not menu items, and a field inside it keeps its focus. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useRef, useState } from 'react' import { Button } from './Button' import { Popover } from './Popover' import { TextInput } from './TextInput' afterEach(cleanup) function Basic({ children, onOpenChange }: { children?: React.ReactNode; onOpenChange?: (open: boolean) => void }) { return ( Trigger} ariaLabel="A panel" onOpenChange={onOpenChange}> {children ?? } ) } describe('Popover', () => { it('is a named panel, portalled out of what rendered it', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('button', { name: 'Trigger' })) const panel = await screen.findByRole('dialog', { name: 'A panel' }) expect(container.contains(panel)).toBe(false) }) /** The distinction from Menu, in one assertion: nothing in here is a * menuitem, so nothing takes the arrow keys or the typeahead. */ it('its contents are contents, not menu items', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Trigger' })) await screen.findByRole('dialog') expect(screen.queryByRole('menu')).toBeNull() expect(screen.queryAllByRole('menuitem')).toHaveLength(0) expect(screen.getByRole('button', { name: 'Inside' })).toBeTruthy() }) /** The link editor is why this component exists: a panel that steals focus * from its own field would be useless. */ it('a field inside keeps the focus its autoFocus asked for', async () => { const user = userEvent.setup() function Field() { const [value, setValue] = useState('') return setValue(e.target.value)} aria-label="Link address" /> } render( , ) await user.click(screen.getByRole('button', { name: 'Trigger' })) const field = await screen.findByRole('textbox', { name: 'Link address' }) expect(document.activeElement).toBe(field) await user.keyboard('abc') expect((field as HTMLInputElement).value).toBe('abc') }) 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('dialog') await user.keyboard('{Escape}') await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) expect(document.activeElement).toBe(trigger) }) it('closes on a press outside', async () => { const user = userEvent.setup() render( <> , ) await user.click(screen.getByRole('button', { name: 'Trigger' })) await screen.findByRole('dialog') await user.click(screen.getByRole('button', { name: 'Elsewhere' })) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) it('a second press of the trigger closes it', async () => { const user = userEvent.setup() render() const trigger = screen.getByRole('button', { name: 'Trigger' }) await user.click(trigger) await screen.findByRole('dialog') await user.click(trigger) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) /** A control inside a panel is content, not a menu row: pressing it does not * close the panel, which is what `actionsRef` is for. */ it('a button inside does not close it; actionsRef does', async () => { const user = userEvent.setup() function WithActions() { const actions = useRef<{ close: () => void; unmount: () => void } | null>(null) return ( Trigger} actionsRef={actions} ariaLabel="A panel"> ) } render() await user.click(screen.getByRole('button', { name: 'Trigger' })) await screen.findByRole('dialog') await user.click(screen.getByRole('button', { name: 'Does nothing' })) expect(screen.queryByRole('dialog')).toBeTruthy() await user.click(screen.getByRole('button', { name: 'Close' })) await waitFor(() => expect(screen.queryByRole('dialog')).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('dialog') expect(onOpenChange).toHaveBeenCalledWith(true) await user.keyboard('{Escape}') expect(onOpenChange).toHaveBeenCalledWith(false) }) /** The other mode: no trigger element, hung from a rect the caller measured. */ it('opens from an anchored rect with no trigger at all', async () => { render( , ) expect(await screen.findByRole('dialog', { name: 'Formatting' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'Trigger' })).toBeNull() expect(screen.getByRole('button', { name: 'Bold' })).toBeTruthy() }) /** * An anchored panel leaves focus where it was. The person is still in * whatever produced it — a text selection means a caret in the text — and a * panel that took the caret would end the edit it exists to serve. */ it('an anchored panel does not take focus', async () => { render( <> , ) const field = screen.getByRole('textbox', { name: 'Where the person is' }) field.focus() await screen.findByRole('dialog') expect(document.activeElement).toBe(field) }) /** An anchored panel has the same two exits as any other. It is the mode * with no trigger, so this is the only place they can be asserted. */ it('an anchored panel still closes on Escape and on a press outside', async () => { const user = userEvent.setup() const onOpenChange = vi.fn() render( <> , ) await screen.findByRole('dialog') await user.keyboard('{Escape}') expect(onOpenChange).toHaveBeenCalledWith(false) onOpenChange.mockClear() await user.click(screen.getByRole('button', { name: 'Elsewhere' })) expect(onOpenChange).toHaveBeenCalledWith(false) }) }) /** * `align="center"` (0.12.2, ADOPTION B21). Peek's selection toolbar is centred * over the text you selected, and kept its own placement arithmetic while only * the two edges were mapped. */ describe('Popover, centred on its anchor', () => { it('caps the scrolling box, not the panel', async () => { /* The cap has to land on the viewport: a `max-h` on the panel is overrun by the viewport's own cap and the content draws through the panel's border. jsdom computes no layout, so the class is what this can hold. */ render( Open} ariaLabel="A panel" maxHeight="max-h-[160px]"> Inside , ) await userEvent.click(screen.getByRole('button', { name: 'Open' })) const panel = await screen.findByRole('dialog') expect(panel.className).not.toContain('max-h-[160px]') const viewport = panel.querySelector('[class*="max-h-"]') expect(viewport?.className).toContain('max-h-[160px]') expect(viewport?.className).not.toContain('available-height') }) it('asks Base UI for the middle', async () => { render( Open} align="center" ariaLabel="A panel"> Inside , ) await userEvent.click(screen.getByRole('button', { name: 'Open' })) const panel = await screen.findByRole('dialog') // Base UI writes the resolved placement on the positioner it owns. const positioner = panel.closest('[data-align]') ?? panel.parentElement expect(positioner?.getAttribute('data-align')).toBe('center') }) }) /** * The padding lives on the scrolling content, so the scrollbar hugs the panel * (D63) — and a caller's padding has to land there too. At 0.12.6 a toolbar's * `p-1` on `className` was added to the content's `p-2` instead of replacing * it, and every toolbar in a Popover grew 8px a side (PLAN Finding 60). * Measured in Chrome after the fix: the toolbar 5px inside the panel's edge, * as on 0.12.5. */ describe('Popover, padding', () => { // The scrolling content is the box the children sit in directly. const contentOf = (child: HTMLElement) => child.parentElement as HTMLElement it('pads its content 8px by default', async () => { render( Open} ariaLabel="A panel"> Inside , ) await userEvent.click(screen.getByRole('button', { name: 'Open' })) const content = contentOf(await screen.findByText('Inside')) expect(content.className).toContain('p-2') }) it('takes a caller’s padding on contentClassName, instead of the 8px', async () => { render( Open} ariaLabel="A toolbar" className="w-auto" contentClassName="p-1"> Inside , ) await userEvent.click(screen.getByRole('button', { name: 'Open' })) const content = contentOf(await screen.findByText('Inside')) expect(content.className.split(/\s+/)).toContain('p-1') expect(content.className.split(/\s+/)).not.toContain('p-2') // And the panel carries none of it. expect((await screen.findByRole('dialog')).className.split(/\s+/)).not.toContain('p-1') }) })