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
*
* ```
*/
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 = [];
}
}