import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Popover } from './popover';
import { POPOVER_CLOSE_EVENT, POPOVER_OPEN_EVENT } from './popover.types';
const MARKUP = `
`;
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);
});
});