From 4d420dfa6de06f82e8b0fb1e45bb10184774d874 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:52:33 +0100 Subject: [PATCH] feat: add Radio primitive (RadioGroup + RadioItem) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Radio/Radio.module.css | 92 ++++++++++++ .../primitives/Radio/Radio.test.tsx | 136 ++++++++++++++++++ src/design-system/primitives/Radio/Radio.tsx | 128 +++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 src/design-system/primitives/Radio/Radio.module.css create mode 100644 src/design-system/primitives/Radio/Radio.test.tsx create mode 100644 src/design-system/primitives/Radio/Radio.tsx diff --git a/src/design-system/primitives/Radio/Radio.module.css b/src/design-system/primitives/Radio/Radio.module.css new file mode 100644 index 0000000..6be91aa --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.module.css @@ -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); +} diff --git a/src/design-system/primitives/Radio/Radio.test.tsx b/src/design-system/primitives/Radio/Radio.test.tsx new file mode 100644 index 0000000..2abd697 --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.test.tsx @@ -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( + + + + + , + ) + expect(screen.getByLabelText('Red')).toBeInTheDocument() + expect(screen.getByLabelText('Blue')).toBeInTheDocument() + expect(screen.getByLabelText('Green')).toBeInTheDocument() + }) + + it('marks the current value as checked', () => { + render( + + + + , + ) + 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( + + + + , + ) + 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( + + + + , + ) + await user.click(screen.getByLabelText('Blue')) + expect(onChange).not.toHaveBeenCalled() + }) + + it('disabled item has disabled attribute', () => { + render( + + + + , + ) + expect(screen.getByLabelText('Blue')).toBeDisabled() + expect(screen.getByLabelText('Red')).not.toBeDisabled() + }) + + it('all inputs share the same name', () => { + render( + + + + + , + ) + 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( + + + + , + ) + 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( + + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toMatch(/horizontal/) + }) + + it('applies vertical layout class by default', () => { + const { container } = render( + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toMatch(/vertical/) + }) + + it('accepts a ReactNode as label', () => { + render( + + Bold Red} /> + , + ) + expect(screen.getByText('Bold Red')).toBeInTheDocument() + }) + + it('applies custom className to RadioGroup', () => { + const { container } = render( + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toContain('custom-class') + }) +}) diff --git a/src/design-system/primitives/Radio/Radio.tsx b/src/design-system/primitives/Radio/Radio.tsx new file mode 100644 index 0000000..ab53f93 --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.tsx @@ -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(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 ( + +
+ {children} +
+
+ ) +} + +// ── 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) { + // 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 ( + + ) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function getGroupInputs(current: HTMLInputElement): HTMLInputElement[] { + const group = current.closest('[role="radiogroup"]') + if (!group) return [] + return Array.from(group.querySelectorAll('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 +}