From 10a9ccfd4220967aa2185bb96a72a61ef0098ab4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:50:24 +0100 Subject: [PATCH 01/23] feat: add Label primitive Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Label/Label.module.css | 11 ++++++ .../primitives/Label/Label.test.tsx | 34 +++++++++++++++++++ src/design-system/primitives/Label/Label.tsx | 20 +++++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/design-system/primitives/Label/Label.module.css create mode 100644 src/design-system/primitives/Label/Label.test.tsx create mode 100644 src/design-system/primitives/Label/Label.tsx diff --git a/src/design-system/primitives/Label/Label.module.css b/src/design-system/primitives/Label/Label.module.css new file mode 100644 index 0000000..b542bf7 --- /dev/null +++ b/src/design-system/primitives/Label/Label.module.css @@ -0,0 +1,11 @@ +.label { + font-family: var(--font-body); + font-size: 12px; + color: var(--text-primary); + font-weight: 500; +} + +.asterisk { + color: var(--error); + margin-left: 2px; +} diff --git a/src/design-system/primitives/Label/Label.test.tsx b/src/design-system/primitives/Label/Label.test.tsx new file mode 100644 index 0000000..afa2fb0 --- /dev/null +++ b/src/design-system/primitives/Label/Label.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Label } from './Label' + +describe('Label', () => { + it('renders label text', () => { + render() + expect(screen.getByText('Email address')).toBeInTheDocument() + }) + + it('does not show asterisk when required is not set', () => { + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('shows asterisk when required', () => { + render() + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('passes htmlFor to the label element', () => { + render() + const label = screen.getByText('Email') + expect(label).toHaveAttribute('for', 'email-input') + }) + + it('forwards ref to the label element', () => { + let ref: HTMLLabelElement | null = null + render( + + ) + expect(ref).toBeInstanceOf(HTMLLabelElement) + }) +}) diff --git a/src/design-system/primitives/Label/Label.tsx b/src/design-system/primitives/Label/Label.tsx new file mode 100644 index 0000000..d343c3f --- /dev/null +++ b/src/design-system/primitives/Label/Label.tsx @@ -0,0 +1,20 @@ +import styles from './Label.module.css' +import { forwardRef, type LabelHTMLAttributes, type ReactNode } from 'react' + +interface LabelProps extends LabelHTMLAttributes { + required?: boolean + children?: ReactNode + className?: string +} + +export const Label = forwardRef( + ({ required, children, className, ...rest }, ref) => { + return ( + + ) + }, +) +Label.displayName = 'Label' 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 02/23] 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 +} From afe1abf7a125db7b1948324b0d68b35e6e9df105 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:54:11 +0100 Subject: [PATCH 03/23] feat: add Textarea primitive Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Textarea/Textarea.module.css | 21 +++++ .../primitives/Textarea/Textarea.test.tsx | 79 +++++++++++++++++++ .../primitives/Textarea/Textarea.tsx | 31 ++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/design-system/primitives/Textarea/Textarea.module.css create mode 100644 src/design-system/primitives/Textarea/Textarea.test.tsx create mode 100644 src/design-system/primitives/Textarea/Textarea.tsx diff --git a/src/design-system/primitives/Textarea/Textarea.module.css b/src/design-system/primitives/Textarea/Textarea.module.css new file mode 100644 index 0000000..1c14d28 --- /dev/null +++ b/src/design-system/primitives/Textarea/Textarea.module.css @@ -0,0 +1,21 @@ +.textarea { + width: 100%; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 12px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + resize: vertical; +} + +.textarea::placeholder { color: var(--text-faint); } +.textarea:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); } + +.resizeVertical { resize: vertical; } +.resizeHorizontal { resize: horizontal; } +.resizeNone { resize: none; } +.resizeBoth { resize: both; } diff --git a/src/design-system/primitives/Textarea/Textarea.test.tsx b/src/design-system/primitives/Textarea/Textarea.test.tsx new file mode 100644 index 0000000..09807bf --- /dev/null +++ b/src/design-system/primitives/Textarea/Textarea.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createRef } from 'react' +import { Textarea } from './Textarea' + +describe('Textarea', () => { + it('renders a textarea element', () => { + render(