import { uid } from '../shared/dom'; import { CHECKBOX_CHANGE_EVENT, RADIO_CHANGE_EVENT, SWITCH_CHANGE_EVENT, type CheckboxChangeDetail, type CheckboxOptions, type RadioChangeDetail, type RadioGroupOptions, type SwitchChangeDetail, type SwitchOptions, } from './choice.types'; const RADIO_SELECTOR = '[data-c42-radio]'; /** * Headless checkbox controller. Wires the `checkbox` ARIA role, tri-state * `aria-checked`, keyboard toggling and `data-state` to an existing element; * it never applies visual styles. * * Markup: * ```html * * ``` */ export class Checkbox { private readonly root: HTMLElement; private isChecked = false; private isIndeterminate = false; private isDisabled = false; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: CheckboxOptions = {}) { if (!root) { throw new Error('[42/checkbox] Needs a root element.'); } this.root = root; this.isChecked = options.checked ?? false; this.isIndeterminate = options.indeterminate ?? false; this.isDisabled = options.disabled ?? false; this.init(); } private init(): void { if (!this.root.id) { this.root.id = uid('checkbox'); } this.root.setAttribute('role', 'checkbox'); const onClick = (): void => { this.toggle(); }; const onKeydown = (event: KeyboardEvent): void => { if (event.key === ' ' || event.key === 'Enter') { if (event.key === ' ') { event.preventDefault(); } this.toggle(); } }; this.root.addEventListener('click', onClick); this.root.addEventListener('keydown', onKeydown); this.cleanups.push(() => { this.root.removeEventListener('click', onClick); this.root.removeEventListener('keydown', onKeydown); }); this.render(); } private render(): void { const state = this.isIndeterminate ? 'indeterminate' : this.isChecked ? 'checked' : 'unchecked'; const ariaChecked = this.isIndeterminate ? 'mixed' : String(this.isChecked); this.root.setAttribute('aria-checked', ariaChecked); this.root.dataset.state = state; if (this.isDisabled) { this.root.setAttribute('aria-disabled', 'true'); this.root.dataset.disabled = ''; this.root.removeAttribute('tabindex'); } else { this.root.removeAttribute('aria-disabled'); delete this.root.dataset.disabled; this.root.setAttribute('tabindex', '0'); } } private emit(): void { const detail: CheckboxChangeDetail = { checked: this.isChecked, indeterminate: this.isIndeterminate, }; this.root.dispatchEvent(new CustomEvent(CHECKBOX_CHANGE_EVENT, { detail, bubbles: true })); } /** Toggle the checked state. Clears indeterminate to checked. No-op if disabled. */ toggle(): void { if (this.isDisabled) { return; } if (this.isIndeterminate) { this.isIndeterminate = false; this.isChecked = true; } else { this.isChecked = !this.isChecked; } this.render(); this.emit(); } /** Set the checked state explicitly. Clears indeterminate. No-op if disabled. */ setChecked(checked: boolean): void { if (this.isDisabled || (checked === this.isChecked && !this.isIndeterminate)) { return; } this.isChecked = checked; this.isIndeterminate = false; this.render(); this.emit(); } /** Set the indeterminate ("mixed") state. No-op if disabled. */ setIndeterminate(indeterminate: boolean): void { if (this.isDisabled || indeterminate === this.isIndeterminate) { return; } this.isIndeterminate = indeterminate; this.render(); this.emit(); } get checked(): boolean { return this.isChecked; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } } /** * Headless switch controller. Wires the `switch` ARIA role, `aria-checked`, * keyboard toggling and `data-state` to an existing element; it never applies * visual styles. * * Markup: * ```html * * ``` */ export class Switch { private readonly root: HTMLElement; private isChecked = false; private isDisabled = false; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SwitchOptions = {}) { if (!root) { throw new Error('[42/switch] Needs a root element.'); } this.root = root; this.isChecked = options.checked ?? false; this.isDisabled = options.disabled ?? false; this.init(); } private init(): void { if (!this.root.id) { this.root.id = uid('switch'); } this.root.setAttribute('role', 'switch'); const onClick = (): void => { this.toggle(); }; const onKeydown = (event: KeyboardEvent): void => { if (event.key === ' ' || event.key === 'Enter') { if (event.key === ' ') { event.preventDefault(); } this.toggle(); } }; this.root.addEventListener('click', onClick); this.root.addEventListener('keydown', onKeydown); this.cleanups.push(() => { this.root.removeEventListener('click', onClick); this.root.removeEventListener('keydown', onKeydown); }); this.render(); } private render(): void { this.root.setAttribute('aria-checked', String(this.isChecked)); this.root.dataset.state = this.isChecked ? 'on' : 'off'; if (this.isDisabled) { this.root.setAttribute('aria-disabled', 'true'); this.root.dataset.disabled = ''; this.root.removeAttribute('tabindex'); } else { this.root.removeAttribute('aria-disabled'); delete this.root.dataset.disabled; this.root.setAttribute('tabindex', '0'); } } private emit(): void { const detail: SwitchChangeDetail = { checked: this.isChecked }; this.root.dispatchEvent(new CustomEvent(SWITCH_CHANGE_EVENT, { detail, bubbles: true })); } /** Toggle the on/off state. No-op if disabled. */ toggle(): void { if (this.isDisabled) { return; } this.isChecked = !this.isChecked; this.render(); this.emit(); } /** Set the on/off state explicitly. No-op if disabled or unchanged. */ setChecked(checked: boolean): void { if (this.isDisabled || checked === this.isChecked) { return; } this.isChecked = checked; this.render(); this.emit(); } get checked(): boolean { return this.isChecked; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } } interface Radio { value: string; el: HTMLElement; } /** * Headless radio-group controller. Wires `radiogroup`/`radio` ARIA roles, * roving tabindex, arrow-key navigation and selection to existing markup; it * never applies visual styles. * * Markup: * ```html *
*
A
*
B
*
* ``` */ export class RadioGroup { private readonly root: HTMLElement; private readonly isDisabled: boolean; private radios: Radio[] = []; private selected: string | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: RadioGroupOptions = {}) { if (!root) { throw new Error('[42/radio] Needs a root element.'); } this.root = root; this.isDisabled = options.disabled ?? false; this.collect(); this.init(options.defaultValue ?? null); } private collect(): void { const els = Array.from(this.root.querySelectorAll(RADIO_SELECTOR)); if (els.length === 0) { throw new Error('[42/radio] Needs at least one [data-c42-radio].'); } this.radios = els.map((el, index) => ({ value: el.dataset.value ?? String(index), el })); } private init(defaultValue: string | null): void { this.root.setAttribute('role', 'radiogroup'); if (this.isDisabled) { this.root.setAttribute('aria-disabled', 'true'); this.root.dataset.disabled = ''; } this.radios.forEach(({ el }) => { el.setAttribute('role', 'radio'); const onClick = (): void => this.select(el); const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event); el.addEventListener('click', onClick); el.addEventListener('keydown', onKeydown); this.cleanups.push(() => { el.removeEventListener('click', onClick); el.removeEventListener('keydown', onKeydown); }); }); this.selected = defaultValue && this.radios.some((radio) => radio.value === defaultValue) ? defaultValue : null; this.render(); } private currentIndex(): number { return this.radios.findIndex((radio) => radio.value === this.selected); } private select(el: HTMLElement): void { if (this.isDisabled) { return; } const radio = this.radios.find((item) => item.el === el); if (radio) { this.setValue(radio.value); } } private onKeydown(event: KeyboardEvent): void { if (this.isDisabled) { return; } const focusedIndex = this.radios.findIndex((radio) => radio.el === document.activeElement); const from = focusedIndex >= 0 ? focusedIndex : Math.max(this.currentIndex(), 0); let next = -1; switch (event.key) { case 'ArrowRight': case 'ArrowDown': next = (from + 1) % this.radios.length; break; case 'ArrowLeft': case 'ArrowUp': next = (from - 1 + this.radios.length) % this.radios.length; break; case ' ': { event.preventDefault(); const radio = this.radios[from]; if (radio) { this.setValue(radio.value); } return; } default: return; } event.preventDefault(); const target = this.radios[next]; if (!target) { return; } target.el.focus(); this.setValue(target.value); } private render(): void { const hasSelection = this.selected !== null; this.radios.forEach(({ value, el }, index) => { const isSelected = value === this.selected; el.setAttribute('aria-checked', String(isSelected)); el.dataset.state = isSelected ? 'checked' : 'unchecked'; const focusable = isSelected || (!hasSelection && index === 0); if (this.isDisabled) { el.setAttribute('aria-disabled', 'true'); el.dataset.disabled = ''; el.setAttribute('tabindex', '-1'); } else { el.removeAttribute('aria-disabled'); delete el.dataset.disabled; el.setAttribute('tabindex', focusable ? '0' : '-1'); } }); } private emit(): void { const detail: RadioChangeDetail = { value: this.selected }; this.root.dispatchEvent(new CustomEvent(RADIO_CHANGE_EVENT, { detail, bubbles: true })); } /** Select a radio by value. No-op if already selected, disabled or unknown. */ setValue(value: string): void { if ( this.isDisabled || value === this.selected || !this.radios.some((radio) => radio.value === value) ) { return; } this.selected = value; this.render(); this.emit(); } get value(): string | null { return this.selected; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }