import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Checkbox, RadioGroup, Switch } from './choice';
import {
CHECKBOX_CHANGE_EVENT,
RADIO_CHANGE_EVENT,
SWITCH_CHANGE_EVENT,
type CheckboxChangeEvent,
type RadioChangeEvent,
type SwitchChangeEvent,
} from './choice.types';
describe('Checkbox', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
function el(): HTMLElement {
return document.body.firstElementChild as HTMLElement;
}
it('wires role and unchecked state by default', () => {
const node = el();
new Checkbox(node);
expect(node.getAttribute('role')).toBe('checkbox');
expect(node.getAttribute('aria-checked')).toBe('false');
expect(node.dataset.state).toBe('unchecked');
expect(node.getAttribute('tabindex')).toBe('0');
});
it('toggles on click', () => {
const node = el();
new Checkbox(node);
node.click();
expect(node.getAttribute('aria-checked')).toBe('true');
expect(node.dataset.state).toBe('checked');
});
it('toggles on Space', () => {
const node = el();
new Checkbox(node);
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(node.getAttribute('aria-checked')).toBe('true');
});
it('renders indeterminate as mixed and resolves to checked on toggle', () => {
const node = el();
const cb = new Checkbox(node, { indeterminate: true });
expect(node.getAttribute('aria-checked')).toBe('mixed');
expect(node.dataset.state).toBe('indeterminate');
cb.toggle();
expect(node.getAttribute('aria-checked')).toBe('true');
});
it('is a no-op when disabled', () => {
const node = el();
new Checkbox(node, { disabled: true });
node.click();
expect(node.getAttribute('aria-checked')).toBe('false');
expect(node.getAttribute('aria-disabled')).toBe('true');
expect(node.hasAttribute('tabindex')).toBe(false);
});
it('emits change events', () => {
const node = el();
const cb = new Checkbox(node);
const spy = vi.fn();
cb.on(CHECKBOX_CHANGE_EVENT, spy);
cb.toggle();
expect(spy.mock.calls[0][0].detail).toEqual({ checked: true, indeterminate: false });
});
it('removes listeners on destroy', () => {
const node = el();
const cb = new Checkbox(node);
cb.destroy();
node.click();
expect(node.getAttribute('aria-checked')).toBe('false');
});
});
describe('Switch', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
function el(): HTMLElement {
return document.body.firstElementChild as HTMLElement;
}
it('wires switch role and off state by default', () => {
const node = el();
new Switch(node);
expect(node.getAttribute('role')).toBe('switch');
expect(node.getAttribute('aria-checked')).toBe('false');
expect(node.dataset.state).toBe('off');
});
it('toggles on click', () => {
const node = el();
new Switch(node);
node.click();
expect(node.getAttribute('aria-checked')).toBe('true');
expect(node.dataset.state).toBe('on');
});
it('respects initial checked option', () => {
const node = el();
const sw = new Switch(node, { checked: true });
expect(sw.checked).toBe(true);
expect(node.dataset.state).toBe('on');
});
it('is a no-op when disabled', () => {
const node = el();
new Switch(node, { disabled: true });
node.click();
expect(node.getAttribute('aria-checked')).toBe('false');
});
it('emits change events', () => {
const node = el();
const sw = new Switch(node);
const spy = vi.fn();
sw.on(SWITCH_CHANGE_EVENT, spy);
sw.toggle();
expect(spy.mock.calls[0][0].detail).toEqual({ checked: true });
});
});
describe('RadioGroup', () => {
const MARKUP = `
`;
beforeEach(() => {
document.body.innerHTML = MARKUP;
});
function root(): HTMLElement {
return document.body.firstElementChild as HTMLElement;
}
function radios(): HTMLElement[] {
return Array.from(document.querySelectorAll('[data-c42-radio]'));
}
it('wires radiogroup and radio roles', () => {
const r = root();
new RadioGroup(r);
expect(r.getAttribute('role')).toBe('radiogroup');
expect(radios()[0].getAttribute('role')).toBe('radio');
expect(radios()[0].getAttribute('aria-checked')).toBe('false');
});
it('throws when there are no radios', () => {
document.body.innerHTML = '';
expect(() => new RadioGroup(document.body.firstElementChild as HTMLElement)).toThrow(
/at least one/,
);
});
it('makes only the first radio focusable when none selected', () => {
new RadioGroup(root());
const [first, second] = radios();
expect(first.getAttribute('tabindex')).toBe('0');
expect(second.getAttribute('tabindex')).toBe('-1');
});
it('selects a radio on click', () => {
const group = new RadioGroup(root());
radios()[1].click();
expect(group.value).toBe('b');
expect(radios()[1].getAttribute('aria-checked')).toBe('true');
expect(radios()[1].getAttribute('tabindex')).toBe('0');
});
it('honors defaultValue', () => {
const group = new RadioGroup(root(), { defaultValue: 'c' });
expect(group.value).toBe('c');
expect(radios()[2].getAttribute('aria-checked')).toBe('true');
});
it('moves and selects with arrow keys (wrapping)', () => {
const group = new RadioGroup(root());
const [first] = radios();
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(group.value).toBe('b');
expect(document.activeElement).toBe(radios()[1]);
});
it('is a no-op when disabled', () => {
const group = new RadioGroup(root(), { disabled: true });
radios()[0].click();
expect(group.value).toBeNull();
expect(root().getAttribute('aria-disabled')).toBe('true');
});
it('emits change events with the selected value', () => {
const group = new RadioGroup(root());
const spy = vi.fn();
group.on(RADIO_CHANGE_EVENT, spy);
group.setValue('a');
expect(spy.mock.calls[0][0].detail).toEqual({ value: 'a' });
});
});