// @vitest-environment jsdom /** * What the pages claim after stage 3, pinned. * * `DialogShell.mdx` said, in as many words, that this component did not trap * focus. It does now, and a sentence that changed from "it does not" to "it * does" is exactly the kind that needs a test under it — otherwise the page is * the only thing holding the claim up. * * The one that is a *decision* rather than a capability is the last pair: * a plain dialog closes when you press outside it, and a `ConfirmDialog` does * not (Katerina, D20, 2026-09-07). Both are asserted, because the difference * is the whole reason `alert` exists. */ import { useState } from 'react' 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 { Button } from './Button' import { ConfirmDialog } from './ConfirmDialog' import { DialogShell } from './DialogShell' import { TextInput } from './TextInput' afterEach(cleanup) const Shell = ({ onClose }: { onClose: () => void }) => ( Save}> ) describe('DialogShell', () => { it('is named by its header', () => { render( {}} />) expect(screen.getByRole('dialog', { name: 'Rename topic' })).toBeTruthy() }) it('is named by the title even when headerContent replaces the header text', () => { // The name used to be spelled with `aria-label` in both branches. Now the // header names it when the header is the title, so this is the branch that // still needs the label — and the one that would silently go unnamed. render( {}} headerContent={Back · Members · 4}>

Body

, ) expect(screen.getByRole('dialog', { name: 'Members' })).toBeTruthy() }) it('closes on Escape', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.keyboard('{Escape}') await waitFor(() => expect(onClose).toHaveBeenCalled()) }) it('closes on the close button', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Close' })) expect(onClose).toHaveBeenCalled() }) it('traps focus, which this component could not do before', async () => { // Written expecting only that Tab could not reach the page behind the // card. Base UI does more than that, and the first version of this test // failed on it: the rest of the document is marked "inert" and // "aria-hidden", so the button behind is not findable by role at all. // Both halves are asserted, because the stronger one is what would go // unnoticed if it weakened. // // The dialog portals to the body, so `container` holds nothing but the // page behind it — which makes "focus never enters the page" one check. const user = userEvent.setup() const { container } = render( <> {}} /> , ) const behind = container.querySelector('button')! expect(behind.textContent).toBe('Behind the dialog') expect(screen.queryByRole('button', { name: 'Behind the dialog' })).toBeNull() // Three controls in the card, so five tabs walk past the end of them. for (let i = 0; i < 5; i++) { await user.tab() expect(container.contains(document.activeElement)).toBe(false) } }) it('focuses the card, not the ✕', async () => { // Base UI's default is the first tabbable element, which here is the close // button — so every dialog opened with a ring on its ✕. The screenshot // diff caught it as a 28px square, and this is the guard so it cannot come // back quietly. render( {}} />) await waitFor(() => { expect(document.activeElement).toBe(screen.getByRole('dialog')) }) }) it('still lets a field with autoFocus take it', async () => { // The two dialogs in Ship rely on this, and pinning the card as the initial // focus is exactly the change that could have broken it. render( {}}> , ) await waitFor(() => { expect(document.activeElement).toBe(screen.getByLabelText('Name')) }) }) it('returns focus to whatever opened it', async () => { const user = userEvent.setup() function Opener() { const [open, setOpen] = useState(false) return ( <> {open && ( setOpen(false)}> Body )} ) } render() const opener = screen.getByRole('button', { name: 'Open' }) await user.click(opener) await screen.findByRole('dialog', { name: 'Rename topic' }) await user.keyboard('{Escape}') await waitFor(() => expect(document.activeElement).toBe(opener)) }) it('closes when you press outside it', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.click(document.querySelector('.bg-scrim') as HTMLElement) await waitFor(() => expect(onClose).toHaveBeenCalled()) }) }) describe('ConfirmDialog', () => { const Confirm = ({ onClose }: { onClose: () => void }) => ( true} onClose={onClose}> This cannot be undone. ) it('does NOT close when you press outside it (D20)', async () => { // The difference from the dialog above, and the reason `alert` exists: a // destructive question is answered, not clicked away. const onClose = vi.fn() const user = userEvent.setup() render() await user.click(document.querySelector('.bg-scrim') as HTMLElement) // Give the close a chance to happen before asserting that it did not. await new Promise((r) => setTimeout(r, 50)) expect(onClose).not.toHaveBeenCalled() expect(screen.getByRole('alertdialog', { name: 'Delete project?' })).toBeTruthy() }) it('still closes on Escape', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.keyboard('{Escape}') await waitFor(() => expect(onClose).toHaveBeenCalled()) }) it('announces itself as an alert rather than a plain dialog', () => { render( {}} />) expect(screen.getByRole('alertdialog')).toBeTruthy() }) }) /** * `bodyMaxHeight` makes the body scroll in the package's bar (0.12.2, * ADOPTION B19). Peek's five dialogs passed `overflow-y-auto` on the body, so * a long roster drew the native bar D40 removed everywhere else — and two of * the five passed it with no cap at all, which never scrolled anything. */ describe('DialogShell, a body that scrolls', () => { it('leaves the body alone without a cap, and wraps it in a scroll region with one', () => { const { container, unmount } = render( {}} bodyClassName="flex flex-col gap-6">

Inside

, ) const plain = screen.getByText('Inside').parentElement! expect(plain.className).toContain('flex flex-col gap-6') expect(plain.className).toContain('pl-5') unmount() render( {}} bodyMaxHeight="max-h-[240px]" bodyClassName="flex flex-col gap-6">

Inside

, ) const content = screen.getByText('Inside').parentElement! // The padding and the layout stay on the box the children are in… expect(content.className).toContain('flex flex-col gap-6') expect(content.className).toContain('pl-5') // …and the cap is on the scrolling box above it, never on the region. const viewport = content.closest('[class*="max-h-"]') expect(viewport).not.toBe(null) expect(viewport).not.toBe(content) expect(viewport!.className).toContain('overflow') }) })