/** * Component tests for the overlay trio: SvModal (dialog semantics, Escape / * backdrop close), SvPopover (click open + outside close) and SvTooltip * (focus-triggered, aria-describedby wiring). */ import { describe, expect, it, vi, afterEach } from 'vitest' import { mount, unmount, flushSync, createRawSnippet } from 'svelte' import SvModal from './SvModal.svelte' import SvPopover from './SvPopover.svelte' import SvTooltip from './SvTooltip.svelte' const body = createRawSnippet(() => ({ render: () => `

hello

` })) const anchorBtn = createRawSnippet(() => ({ render: () => `` })) function mountC(Comp: any, props: Record) { const target = document.createElement('div') document.body.appendChild(target) const app = mount(Comp, { target, props: props as any }) flushSync() return { target, destroy: () => { unmount(app); target.remove() } } } afterEach(() => { document.querySelectorAll('.sv-modal__backdrop, .sv-pop__panel, .sv-tip').forEach((n) => n.remove()) }) describe('SvModal', () => { it('renders a labelled modal dialog when open', () => { const { destroy } = mountC(SvModal, { open: true, title: 'Edit', children: body }) try { const dialog = document.querySelector('.sv-modal')! expect(dialog.getAttribute('role')).toBe('dialog') expect(dialog.getAttribute('aria-modal')).toBe('true') const titleId = dialog.getAttribute('aria-labelledby')! expect(document.getElementById(titleId)!.textContent).toBe('Edit') expect(document.querySelector('.sv-modal .body')!.textContent).toBe('hello') } finally { destroy() } }) it('Escape and the close button both fire onClose', () => { let closed = 0 const { destroy } = mountC(SvModal, { open: true, title: 'X', onClose: () => (closed++), children: body }) try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) flushSync() expect(closed).toBe(1) } finally { destroy() } }) }) describe('SvPopover', () => { it('opens the panel on anchor click and closes on outside pointerdown', () => { const { target, destroy } = mountC(SvPopover, { anchor: anchorBtn, children: body }) try { expect(document.querySelector('.sv-pop__panel')).toBeNull() target.querySelector('.anchor')!.click() flushSync() const panel = document.querySelector('.sv-pop__panel')! expect(panel).not.toBeNull() expect(panel.getAttribute('role')).toBe('dialog') // Outside click closes. document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) flushSync() expect(document.querySelector('.sv-pop__panel')).toBeNull() } finally { destroy() } }) }) describe('SvTooltip', () => { it('shows after the delay on focus and wires aria-describedby', () => { vi.useFakeTimers() try { const child = createRawSnippet(() => ({ render: () => `` })) const { target, destroy } = mountC(SvTooltip, { text: 'Tip text', delay: 200, children: child }) const anchor = target.querySelector('.sv-tip__anchor')! anchor.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) vi.advanceTimersByTime(210) flushSync() const tip = document.querySelector('.sv-tip')! expect(tip).not.toBeNull() expect(tip.getAttribute('role')).toBe('tooltip') expect(anchor.getAttribute('aria-describedby')).toBe(tip.id) // The wrapper span is never itself focusable, so AT users only ever land // on the real interactive child below - it must carry the description // too, or it's never announced on focus (see SvTooltip.svelte effect). const focusEl = target.querySelector('.t')! expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id) destroy() } finally { vi.useRealTimers() } }) })