/** * createRadioGroup - the HEADLESS core behind : an accessible * single-select radio group (WAI-ARIA `radiogroup`) with roving tabindex + full * arrow-key navigation, exposed as **prop-getters** you spread onto YOUR OWN * markup. Keyboard/ARIA/roving-focus come from the core, the DOM is yours. * * ```svelte * *
* {#each options as opt (opt.value)} * * {/each} *
* ``` * * The styled is just one renderer over this core. */ export type RadioOption = { value: string | number; label: string; disabled?: boolean } import { editorAria, type EditorAriaState } from './editor-contract' /** Reactive inputs are passed as getters so the core tracks live prop changes. */ export type RadioGroupConfig = { options: () => ReadonlyArray value: () => string | number | null onChange?: (value: string | number) => void disabled?: () => boolean ariaLabel?: () => string | undefined // Editor contract (ARIA + validation) - folded into groupProps(). id?: () => string | undefined invalid?: () => boolean required?: () => boolean error?: () => string | undefined hint?: () => string | undefined } export type RadioProps = { type: 'button' role: 'radio' 'aria-checked': boolean 'data-idx': number 'data-checked': '' | undefined disabled: boolean tabindex: number onclick: () => void } export type RadioGroupProps = { role: 'radiogroup' id: string | undefined 'aria-label': string | undefined 'aria-invalid': 'true' | undefined 'aria-required': 'true' | undefined 'aria-describedby': string | undefined 'aria-disabled': boolean onkeydown: (e: KeyboardEvent) => void } export type RadioGroup = { /** Currently selected value. */ readonly value: string | number | null readonly disabled: boolean isChecked: (option: RadioOption) => boolean /** Select by value (no-op when disabled / option disabled / unknown). */ select: (value: string | number) => void onKeydown: (e: KeyboardEvent) => void /** Spread onto the group container element. */ groupProps: () => RadioGroupProps /** Spread onto each option element. */ radioProps: (option: RadioOption) => RadioProps } export function createRadioGroup(config: RadioGroupConfig): RadioGroup { const opts = () => config.options() const disabled = () => config.disabled?.() ?? false const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0)) const selectedIdx = $derived(opts().findIndex((o) => o.value === config.value())) // Roving-tabindex target: the selected option, else the first enabled one. const focusIdx = $derived(selectedIdx >= 0 ? selectedIdx : enabledIdx[0] ?? -1) function pickIndex(i: number) { const o = opts()[i] if (!o || o.disabled || disabled()) return config.onChange?.(o.value) } function select(value: string | number) { pickIndex(opts().findIndex((o) => o.value === value)) } // Arrow handling lives on the group container so it works for ANY markup: // the originating radio is found by role + data-idx, so nothing is assumed // about how you nest your elements. function onKeydown(e: KeyboardEvent) { if (disabled()) return const container = e.currentTarget as HTMLElement const origin = (e.target as HTMLElement | null)?.closest('[role="radio"]') const i = origin ? Number(origin.getAttribute('data-idx')) : focusIdx const pos = enabledIdx.indexOf(i) if (pos < 0) return if (e.key === ' ') { e.preventDefault(); pickIndex(i); return } let nextPos = pos if (e.key === 'ArrowDown' || e.key === 'ArrowRight') nextPos = (pos + 1) % enabledIdx.length else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') nextPos = (pos - 1 + enabledIdx.length) % enabledIdx.length else return e.preventDefault() const target = enabledIdx[nextPos]! pickIndex(target) container.querySelectorAll('[role="radio"]')[target]?.focus() } const ariaState = (): EditorAriaState => ({ id: config.id?.(), invalid: config.invalid?.(), required: config.required?.(), error: config.error?.(), hint: config.hint?.(), ariaLabel: config.ariaLabel?.(), }) return { get value() { return config.value() }, get disabled() { return disabled() }, isChecked: (option: RadioOption) => option.value === config.value(), select, onKeydown, groupProps: () => ({ role: 'radiogroup', id: config.id?.(), 'aria-disabled': disabled(), ...editorAria(ariaState()), onkeydown: onKeydown, }), radioProps: (option: RadioOption) => { const i = opts().findIndex((o) => o.value === option.value) const checked = option.value === config.value() return { type: 'button', role: 'radio', 'aria-checked': checked, 'data-idx': i, 'data-checked': checked ? '' : undefined, disabled: disabled() || !!option.disabled, tabindex: i === focusIdx ? 0 : -1, onclick: () => pickIndex(i), } }, } }