// @vitest-environment jsdom
/**
* What the Toolbar page claims, pinned — and the reason the component exists
* is the first test: a strip of controls is one Tab stop, not one per button.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IconButton } from './IconButton'
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator } from './Toolbar'
import { Menu, MenuItem } from './Menu'
import { Popover } from './Popover'
afterEach(cleanup)
const dot = ·
function Strip({ label = 'Formatting' }: { label?: string }) {
return (
<>
{dot}{dot}{dot}
>
)
}
const focused = () => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent
describe('Toolbar', () => {
it('is a named toolbar', () => {
render()
expect(screen.getByRole('toolbar', { name: 'Formatting' })).toBeTruthy()
})
/** The whole point. Three buttons, one stop. */
it('is one Tab stop, whatever it holds', async () => {
const user = userEvent.setup()
render()
screen.getByRole('button', { name: 'Before' }).focus()
await user.tab()
expect(focused()).toBe('One')
await user.tab()
expect(focused()).toBe('After')
})
it('and coming back lands on the toolbar, not inside it', async () => {
const user = userEvent.setup()
render()
screen.getByRole('button', { name: 'After' }).focus()
await user.tab({ shift: true })
expect(focused()).toBe('One')
})
it('the arrow keys walk it, and wrap', async () => {
const user = userEvent.setup()
render()
screen.getByRole('button', { name: 'One' }).focus()
await user.keyboard('{ArrowRight}')
expect(focused()).toBe('Two')
await user.keyboard('{ArrowRight}{ArrowRight}')
expect(focused()).toBe('One')
await user.keyboard('{ArrowLeft}')
expect(focused()).toBe('Three')
})
/**
* The gap, pinned as a gap. Base UI's composite implements Home and End
* behind `enableHomeAndEndKeys`, and `Toolbar.Root` does not pass it — so
* they do nothing here, the page does not claim them, and this test fails
* the day that changes.
*/
it('Home and End do nothing, which is Base UI’s choice and is stated', async () => {
const user = userEvent.setup()
render()
screen.getByRole('button', { name: 'Two' }).focus()
await user.keyboard('{End}')
expect(focused()).toBe('Two')
await user.keyboard('{Home}')
expect(focused()).toBe('Two')
})
it('a vertical toolbar walks with ↑ and ↓', async () => {
const user = userEvent.setup()
render(
{dot}{dot},
)
expect(screen.getByRole('toolbar').getAttribute('aria-orientation')).toBe('vertical')
screen.getByRole('button', { name: 'One' }).focus()
await user.keyboard('{ArrowDown}')
expect(focused()).toBe('Two')
})
it('a button still runs its onClick, and is still an IconButton', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
{dot},
)
const button = screen.getByRole('button', { name: 'One' })
expect(button.tagName).toBe('BUTTON')
await user.click(button)
expect(onClick).toHaveBeenCalledTimes(1)
})
/**
* A strip whose controls come and go from the arrow keys as their state
* changes is a strip you cannot learn — and a `disabledReason` you cannot
* reach is a reason nobody reads.
*/
it('a disabled control keeps its place in the walk', async () => {
const user = userEvent.setup()
render(
{dot}{dot}{dot},
)
screen.getByRole('button', { name: 'One' }).focus()
await user.keyboard('{ArrowRight}')
expect(focused()).toBe('Two')
expect(screen.getByRole('button', { name: 'Two' }).getAttribute('aria-disabled')).toBe('true')
})
/** The box, which a floating strip needs and Peek had drawn twice. */
it('draws the elevated box, and drops it on request', () => {
const withBox = render({dot})
const panel = withBox.container.firstElementChild as HTMLElement
expect(panel.className).toContain('bg-bg-elevated')
expect(panel.querySelector('[role="toolbar"]')).toBeTruthy()
// The strip stays a row inside a panel that is a column for a menu's rows.
expect(panel.querySelector('[role="toolbar"]')!.className).not.toContain('flex-col')
withBox.unmount()
const bare = render({dot})
expect((bare.container.firstElementChild as HTMLElement).getAttribute('role')).toBe('toolbar')
})
it('a separator is a separator, not just a hairline', () => {
render(
{dot}{dot},
)
expect(screen.getByRole('separator')).toBeTruthy()
})
/** While a field has focus the arrow keys are the caret's, which is what a
* field is for; Tab resumes the walk. */
it('a field in the strip keeps the arrow keys for its caret', async () => {
const user = userEvent.setup()
render(
{dot},
)
const field = screen.getByRole('textbox', { name: 'Link address' }) as HTMLInputElement
field.focus()
field.setSelectionRange(3, 3)
await user.keyboard('{ArrowLeft}')
expect(document.activeElement).toBe(field)
expect(field.selectionStart).toBe(2)
})
})
describe('a link in the strip (F3)', () => {
it('is a real link, in the walk with the buttons beside it', async () => {
const user = userEvent.setup()
render(
<>
{dot}{dot}
>,
)
const link = screen.getByRole('link', { name: 'Open' })
expect(link.getAttribute('href')).toBe('/somewhere')
await user.click(screen.getByRole('button', { name: 'Before' }))
await user.tab()
expect(focused()).toBe('One')
await user.keyboard('{ArrowRight}')
expect(document.activeElement).toBe(link)
expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
})
})
describe('a loose row, for comparison', () => {
/** The measurement the component exists to change: the same three buttons
* outside a toolbar are three Tab stops. */
it('is one Tab stop per button', async () => {
const user = userEvent.setup()
render(
<>
{dot}{dot}{dot}
>,
)
screen.getByRole('button', { name: 'Before' }).focus()
const walk: (string | null | undefined)[] = []
for (let i = 0; i < 4; i++) { await user.tab(); walk.push(focused()) }
expect(walk).toEqual(['One', 'Two', 'Three', 'After'])
})
})
describe('a ToolbarButton with a reason, as a trigger', () => {
/*
Found by Ship's adoption (2026-09-08, Finding 39): the message's tools put
"React" (a Popover trigger) and "…" (a Menu trigger) on one strip, and with
a `disabledReason` both came out `aria-disabled="false"`. The part that
renders the trigger writes its own disabled state over the button's.
*/
it('stays disabled, reachable, and closed as a Popover trigger', async () => {
const user = userEvent.setup()
render(
{dot}
}
>