feat: add Radio primitive (RadioGroup + RadioItem)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
src/design-system/primitives/Radio/Radio.module.css
Normal file
92
src/design-system/primitives/Radio/Radio.module.css
Normal 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);
|
||||
}
|
||||
136
src/design-system/primitives/Radio/Radio.test.tsx
Normal file
136
src/design-system/primitives/Radio/Radio.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
128
src/design-system/primitives/Radio/Radio.tsx
Normal file
128
src/design-system/primitives/Radio/Radio.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user