feat: add Radio primitive (RadioGroup + RadioItem)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 11:52:33 +01:00
parent 10a9ccfd42
commit 4d420dfa6d
3 changed files with 356 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
/* ── RadioGroup layout ────────────────────────────────────────────────────── */
.group {
display: flex;
}
.vertical {
flex-direction: column;
gap: 8px;
}
.horizontal {
flex-direction: row;
gap: 16px;
}
/* ── RadioItem wrapper ────────────────────────────────────────────────────── */
.wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.wrapperDisabled {
cursor: not-allowed;
opacity: 0.6;
}
/* ── Hidden native input ──────────────────────────────────────────────────── */
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
}
/* ── Custom circle ────────────────────────────────────────────────────────── */
.circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-raised);
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
/* Inner dot when checked */
.circle::after {
content: '';
display: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
}
.input:checked + .circle {
border-color: var(--amber);
background: var(--bg-raised);
}
.input:checked + .circle::after {
display: block;
}
.input:focus-visible + .circle {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.input:disabled + .circle {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Label ────────────────────────────────────────────────────────────────── */
.label {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
}

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RadioGroup, RadioItem } from './Radio'
describe('RadioGroup + RadioItem', () => {
it('renders all options with correct labels', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
<RadioItem value="green" label="Green" />
</RadioGroup>,
)
expect(screen.getByLabelText('Red')).toBeInTheDocument()
expect(screen.getByLabelText('Blue')).toBeInTheDocument()
expect(screen.getByLabelText('Green')).toBeInTheDocument()
})
it('marks the current value as checked', () => {
render(
<RadioGroup name="color" value="blue" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
expect(screen.getByLabelText('Red')).not.toBeChecked()
expect(screen.getByLabelText('Blue')).toBeChecked()
})
it('calls onChange with the selected value when clicking an option', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
await user.click(screen.getByLabelText('Blue'))
expect(onChange).toHaveBeenCalledWith('blue')
})
it('does not call onChange when clicking a disabled item', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" disabled />
</RadioGroup>,
)
await user.click(screen.getByLabelText('Blue'))
expect(onChange).not.toHaveBeenCalled()
})
it('disabled item has disabled attribute', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" disabled />
</RadioGroup>,
)
expect(screen.getByLabelText('Blue')).toBeDisabled()
expect(screen.getByLabelText('Red')).not.toBeDisabled()
})
it('all inputs share the same name', () => {
render(
<RadioGroup name="size" value="md" onChange={vi.fn()}>
<RadioItem value="sm" label="Small" />
<RadioItem value="md" label="Medium" />
<RadioItem value="lg" label="Large" />
</RadioGroup>,
)
const inputs = screen.getAllByRole('radio')
inputs.forEach((input) => {
expect(input).toHaveAttribute('name', 'size')
})
})
it('supports keyboard navigation between items', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
const redInput = screen.getByLabelText('Red')
redInput.focus()
await user.keyboard('{ArrowDown}')
expect(onChange).toHaveBeenCalledWith('blue')
})
it('applies horizontal layout class when orientation is horizontal', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()} orientation="horizontal">
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toMatch(/horizontal/)
})
it('applies vertical layout class by default', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toMatch(/vertical/)
})
it('accepts a ReactNode as label', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label={<strong>Bold Red</strong>} />
</RadioGroup>,
)
expect(screen.getByText('Bold Red')).toBeInTheDocument()
})
it('applies custom className to RadioGroup', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()} className="custom-class">
<RadioItem value="red" label="Red" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toContain('custom-class')
})
})

View File

@@ -0,0 +1,128 @@
import styles from './Radio.module.css'
import { createContext, useContext, type ReactNode } from 'react'
interface RadioGroupContextValue {
name: string
value: string
onChange: (value: string) => void
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
function useRadioGroup(): RadioGroupContextValue {
const ctx = useContext(RadioGroupContext)
if (!ctx) {
throw new Error('RadioItem must be used inside a RadioGroup')
}
return ctx
}
// ── RadioGroup ────────────────────────────────────────────────────────────────
interface RadioGroupProps {
name: string
value: string
onChange: (value: string) => void
orientation?: 'vertical' | 'horizontal'
children?: ReactNode
className?: string
}
export function RadioGroup({
name,
value,
onChange,
orientation = 'vertical',
children,
className,
}: RadioGroupProps) {
return (
<RadioGroupContext.Provider value={{ name, value, onChange }}>
<div
role="radiogroup"
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
>
{children}
</div>
</RadioGroupContext.Provider>
)
}
// ── RadioItem ─────────────────────────────────────────────────────────────────
interface RadioItemProps {
value: string
label: ReactNode
disabled?: boolean
}
export function RadioItem({ value, label, disabled = false }: RadioItemProps) {
const ctx = useRadioGroup()
const inputId = `radio-${ctx.name}-${value}`
const isChecked = ctx.value === value
function handleChange() {
if (!disabled) {
ctx.onChange(value)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
// Native radio keyboard behaviour fires onChange on ArrowDown/Up/Left/Right
// but since we control the value externally we need to relay those events.
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault()
const inputs = getGroupInputs(e.currentTarget)
const next = getNextEnabled(inputs, e.currentTarget, 1)
if (next) {
ctx.onChange(next.value)
next.focus()
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault()
const inputs = getGroupInputs(e.currentTarget)
const prev = getNextEnabled(inputs, e.currentTarget, -1)
if (prev) {
ctx.onChange(prev.value)
prev.focus()
}
}
}
return (
<label className={`${styles.wrapper} ${disabled ? styles.wrapperDisabled : ''}`} htmlFor={inputId}>
<input
id={inputId}
type="radio"
name={ctx.name}
value={value}
checked={isChecked}
disabled={disabled}
className={styles.input}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<span className={styles.circle} aria-hidden="true" />
{label && <span className={styles.label}>{label}</span>}
</label>
)
}
// ── helpers ───────────────────────────────────────────────────────────────────
function getGroupInputs(current: HTMLInputElement): HTMLInputElement[] {
const group = current.closest('[role="radiogroup"]')
if (!group) return []
return Array.from(group.querySelectorAll<HTMLInputElement>('input[type="radio"]:not(:disabled)'))
}
function getNextEnabled(
inputs: HTMLInputElement[],
current: HTMLInputElement,
direction: 1 | -1,
): HTMLInputElement | null {
const idx = inputs.indexOf(current)
if (idx === -1) return null
const next = (idx + direction + inputs.length) % inputs.length
return inputs[next] ?? null
}