import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Tabs } from './tabs';
import { TABS_CHANGE_EVENT, type TabsChangeEvent } from './tabs.types';
const MARKUP = `
Panel A
Panel B
Panel C
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function triggers(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-tabs-trigger]'));
}
function panels(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-tabs-panel]'));
}
describe('Tabs', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wires ARIA roles and selects the first tab by default', () => {
const root = setup();
new Tabs(root);
const [first, second] = triggers(root);
const [panelA] = panels(root);
expect(root.querySelector('[data-c42-tabs-list]')!.getAttribute('role')).toBe('tablist');
expect(first.getAttribute('role')).toBe('tab');
expect(first.getAttribute('aria-selected')).toBe('true');
expect(first.getAttribute('aria-controls')).toBe(panelA.id);
expect(first.getAttribute('tabindex')).toBe('0');
expect(second.getAttribute('tabindex')).toBe('-1');
expect(panelA.getAttribute('role')).toBe('tabpanel');
expect(panelA.hasAttribute('hidden')).toBe(false);
});
it('honors defaultValue', () => {
const root = setup();
new Tabs(root, { defaultValue: 'b' });
const [, second] = triggers(root);
expect(second.getAttribute('aria-selected')).toBe('true');
});
it('throws when the list is missing', () => {
document.body.innerHTML = '';
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Tabs(root)).toThrow(/data-c42-tabs-list/);
});
it('throws when a panel is missing for a tab', () => {
document.body.innerHTML = `
`;
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Tabs(root)).toThrow(/No panel found/);
});
it('selects a tab on click and toggles panel visibility', () => {
const root = setup();
new Tabs(root);
const [, second] = triggers(root);
const [panelA, panelB] = panels(root);
second.click();
expect(second.getAttribute('aria-selected')).toBe('true');
expect(panelB.hasAttribute('hidden')).toBe(false);
expect(panelA.hasAttribute('hidden')).toBe(true);
});
it('moves focus and auto-activates with arrow keys', () => {
const root = setup();
new Tabs(root);
const [first, second] = triggers(root);
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(second);
expect(second.getAttribute('aria-selected')).toBe('true');
});
it('wraps focus from last to first with ArrowRight', () => {
const root = setup();
new Tabs(root);
const all = triggers(root);
const last = all[all.length - 1]!;
last.focus();
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(all[0]);
});
it('only moves focus in manual mode until Enter', () => {
const root = setup();
new Tabs(root, { activationMode: 'manual' });
const [first, second] = triggers(root);
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(second);
expect(second.getAttribute('aria-selected')).toBe('false');
second.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(second.getAttribute('aria-selected')).toBe('true');
});
it('uses vertical arrow keys when orientation is vertical', () => {
const root = setup();
new Tabs(root, { orientation: 'vertical' });
const [first, second] = triggers(root);
expect(root.querySelector('[data-c42-tabs-list]')!.getAttribute('aria-orientation')).toBe(
'vertical',
);
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(document.activeElement).toBe(second);
});
it('emits change events with the selected value', () => {
const root = setup();
const tabs = new Tabs(root);
const spy = vi.fn();
tabs.on(TABS_CHANGE_EVENT, spy);
triggers(root)[1].click();
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].detail.value).toBe('b');
});
it('removes listeners on destroy', () => {
const root = setup();
const tabs = new Tabs(root);
tabs.destroy();
const [, second] = triggers(root);
second.click();
expect(second.getAttribute('aria-selected')).toBe('false');
});
});