import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Popover } from './popover'; import { POPOVER_CLOSE_EVENT, POPOVER_OPEN_EVENT } from './popover.types'; const MARKUP = `
Link
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function trigger(root: HTMLElement): HTMLButtonElement { return root.querySelector('[data-c42-popover-trigger]')!; } function content(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-popover-content]')!; } describe('Popover', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('wires ARIA and hides the content on init', () => { const root = setup(); new Popover(root); expect(trigger(root).getAttribute('aria-haspopup')).toBe('dialog'); expect(trigger(root).getAttribute('aria-expanded')).toBe('false'); expect(trigger(root).getAttribute('aria-controls')).toBe(content(root).id); expect(content(root).getAttribute('role')).toBe('dialog'); expect(content(root).hasAttribute('hidden')).toBe(true); }); it('toggles open and closed on trigger click', () => { const root = setup(); const pop = new Popover(root); trigger(root).click(); expect(pop.isOpen).toBe(true); expect(trigger(root).getAttribute('aria-expanded')).toBe('true'); expect(content(root).hasAttribute('hidden')).toBe(false); trigger(root).click(); expect(pop.isOpen).toBe(false); }); it('moves focus into the content on open by default', () => { const root = setup(); const pop = new Popover(root); pop.openPopover(); expect(document.activeElement).toBe(content(root).querySelector('a')); }); it('does not move focus when autoFocus is false', () => { const root = setup(); trigger(root).focus(); const pop = new Popover(root, { autoFocus: false }); pop.openPopover(); expect(document.activeElement).toBe(trigger(root)); }); it('closes on Escape and returns focus to the trigger', () => { const root = setup(); const pop = new Popover(root); pop.openPopover(); content(root).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(pop.isOpen).toBe(false); expect(document.activeElement).toBe(trigger(root)); }); it('closes on outside pointerdown', () => { const root = setup(); const pop = new Popover(root); pop.openPopover(); document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); expect(pop.isOpen).toBe(false); }); it('does not close when pointerdown is inside the popover', () => { const root = setup(); const pop = new Popover(root); pop.openPopover(); content(root).dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); expect(pop.isOpen).toBe(true); }); it('keeps open on outside click when closeOnOutsideClick is false', () => { const root = setup(); const pop = new Popover(root, { closeOnOutsideClick: false }); pop.openPopover(); document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); expect(pop.isOpen).toBe(true); }); it('emits open and close events', () => { const root = setup(); const pop = new Popover(root); const onOpen = vi.fn(); const onClose = vi.fn(); pop.on(POPOVER_OPEN_EVENT, onOpen); pop.on(POPOVER_CLOSE_EVENT, onClose); pop.openPopover(); pop.close(); expect(onOpen).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce(); }); it('throws when trigger or content is missing', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Popover(root)).toThrow(/trigger and a content/); }); it('stops reacting after destroy', () => { const root = setup(); const pop = new Popover(root); pop.destroy(); trigger(root).click(); expect(pop.isOpen).toBe(false); }); });