Template
The Overlay refactor dropped the early return, so the fixed-inset backdrop stayed mounted permanently: the palette covered the page and swallowed every click. Restore the guard and cover it with tests.
210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
import { fireEvent, render, screen } from '@testing-library/react'
|
|
import { useState } from 'react'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../src/components/ui/Dialog'
|
|
import { Sheet, SheetClose, SheetContent, SheetTitle } from '../src/components/ui/Sheet'
|
|
import { SearchPalette } from '../src/components/SearchPalette'
|
|
|
|
describe('Dialog (Overlay primitive)', () => {
|
|
it('renders a modal dialog with the panel focused when open', () => {
|
|
render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
|
|
const panel = screen.getByRole('dialog')
|
|
expect(panel).toHaveClass('dialog-panel')
|
|
expect(panel).toHaveAttribute('aria-modal', 'true')
|
|
expect(panel).toHaveFocus()
|
|
})
|
|
|
|
it('wires aria-labelledby only when a title is rendered', () => {
|
|
const { rerender } = render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
const panel = screen.getByRole('dialog')
|
|
const labelledBy = panel.getAttribute('aria-labelledby')
|
|
expect(labelledBy).toBeTruthy()
|
|
expect(document.getElementById(labelledBy!)).toHaveTextContent('Deploy')
|
|
|
|
// No DialogTitle → a dangling IDREF would announce nothing.
|
|
rerender(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<div>body only</div>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-labelledby')
|
|
})
|
|
|
|
it('wires aria-describedby only when a description is rendered', () => {
|
|
render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
<DialogDescription>To production</DialogDescription>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
const describedBy = screen.getByRole('dialog').getAttribute('aria-describedby')
|
|
expect(describedBy).toBeTruthy()
|
|
expect(document.getElementById(describedBy!)).toHaveTextContent('To production')
|
|
})
|
|
|
|
it('restores focus to the trigger after closing', () => {
|
|
function Controlled() {
|
|
const [open, setOpen] = useState(false)
|
|
return (
|
|
<>
|
|
<button onClick={() => setOpen(true)}>Open</button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
render(<Controlled />)
|
|
const trigger = screen.getByRole('button', { name: 'Open' })
|
|
// jsdom does not move focus on synthetic clicks — focus the trigger like a real user would.
|
|
trigger.focus()
|
|
fireEvent.click(trigger)
|
|
expect(screen.getByRole('dialog')).toHaveFocus()
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(trigger).toHaveFocus()
|
|
})
|
|
|
|
it('closes on Escape when open', () => {
|
|
render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('closes on backdrop click', () => {
|
|
render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
fireEvent.click(screen.getByRole('dialog').parentElement!)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('locks body scroll while open and restores it on close', () => {
|
|
const { rerender } = render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
expect(document.body.style.overflow).toBe('hidden')
|
|
rerender(
|
|
<Dialog open={false}>
|
|
<DialogContent>
|
|
<DialogTitle>Deploy</DialogTitle>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
)
|
|
expect(document.body.style.overflow).not.toBe('hidden')
|
|
})
|
|
})
|
|
|
|
describe('Sheet (Overlay skin)', () => {
|
|
it('renders with sheet classes and its own close label', () => {
|
|
render(
|
|
<Sheet defaultOpen>
|
|
<SheetContent>
|
|
<SheetTitle>Filters</SheetTitle>
|
|
<SheetClose>Done</SheetClose>
|
|
</SheetContent>
|
|
</Sheet>,
|
|
)
|
|
const panel = screen.getByRole('dialog')
|
|
expect(panel).toHaveClass('sheet-panel')
|
|
expect(screen.getByRole('button', { name: 'Close sheet' })).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Done' }))
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('keeps Tab focus inside the panel (wrap from last to first)', () => {
|
|
render(
|
|
<Sheet defaultOpen>
|
|
<SheetContent>
|
|
<SheetTitle>Filters</SheetTitle>
|
|
<button>Only control</button>
|
|
</SheetContent>
|
|
</Sheet>,
|
|
)
|
|
const panel = screen.getByRole('dialog')
|
|
const closeBtn = screen.getByRole('button', { name: 'Close sheet' })
|
|
const last = screen.getByRole('button', { name: 'Only control' })
|
|
|
|
// Tab from the last control wraps to the first.
|
|
last.focus()
|
|
fireEvent.keyDown(panel, { key: 'Tab' })
|
|
expect(closeBtn).toHaveFocus()
|
|
|
|
// Shift+Tab from the first control wraps to the last.
|
|
fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true })
|
|
expect(last).toHaveFocus()
|
|
})
|
|
})
|
|
|
|
describe('SearchPalette (Overlay behavior)', () => {
|
|
it('renders nothing while closed and no backdrop is left over the page', () => {
|
|
const { rerender } = render(
|
|
<SearchPalette isOpen={false} onClose={() => {}} onSelect={() => {}} />,
|
|
)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(document.querySelector('.search-palette-backdrop')).toBeNull()
|
|
expect(document.body.style.overflow).toBe('')
|
|
|
|
rerender(<SearchPalette isOpen onClose={() => {}} onSelect={() => {}} />)
|
|
expect(screen.getByRole('dialog')).toHaveClass('search-palette-modal')
|
|
})
|
|
|
|
it('closes on Escape / backdrop click and unlocks body scroll', () => {
|
|
function Controlled() {
|
|
const [open, setOpen] = useState(true)
|
|
return (
|
|
<>
|
|
<button onClick={() => setOpen(true)}>Open</button>
|
|
<SearchPalette isOpen={open} onClose={() => setOpen(false)} onSelect={() => {}} />
|
|
</>
|
|
)
|
|
}
|
|
const { container } = render(<Controlled />)
|
|
expect(document.body.style.overflow).toBe('hidden')
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' })
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(document.body.style.overflow).toBe('')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
fireEvent.click(container.querySelector('.search-palette-backdrop')!)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(document.body.style.overflow).toBe('')
|
|
})
|
|
})
|