Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 92ffaae1de Separate ListRow trailing control and fail fast on out-of-range Slider
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m32s
ListRow with onClick now renders a container whose leading slot, title
and meta form one button, with the trailing slot as a sibling outside
it, so a trailing toggle or action is never nested in the row button
and never triggers the row's action.

Slider throws when value is outside [min, max] (including NaN) instead
of rendering a fill that disagrees with the clamped native input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:28:00 -04:00
dev_agentandClaude Opus 5.5 d9a26b81ec Add core UI primitives in web/src/ui
CI / ci (push) Successful in 2m32s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 medium)
CI / ci (pull_request) Successful in 1m35s
Button, IconButton, Tag, Chip, TextInput, Segmented, Toggle, Checkbox,
Slider, Card and ListRow, styled with CSS Modules over the design tokens.
Every interactive primitive keeps a hit area of at least 44 px; visually
smaller controls draw their face inside a larger hit box.

Vitest now injects CSS Modules into jsdom (css: true) so tests assert the
sizes the stylesheets declare, alongside roles, aria states and keyboard
behaviour (Segmented arrow keys, Toggle Space/Enter, Chip "Remove X").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:23:42 -04:00
36 changed files with 1408 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { expect } from 'vitest'
// Minimum tap target from the handoff's accessibility rules.
export const MIN_HIT_AREA = 44
function px(value: string): number {
return value.endsWith('px') ? Number.parseFloat(value) : 0
}
// The tap target an element's stylesheet guarantees: the larger of its
// declared size and declared minimum size, per axis. jsdom does no layout, so
// this reads the CSS Modules' computed values (Vitest runs with css: true).
// A dimension the CSS leaves unsized counts as 0 and fails the check.
export function hitArea(element: Element): { width: number; height: number } {
const style = getComputedStyle(element)
return {
width: Math.max(px(style.width), px(style.minWidth)),
height: Math.max(px(style.height), px(style.minHeight)),
}
}
export function expectMinHitArea(element: Element) {
const { width, height } = hitArea(element)
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
}
+63
View File
@@ -0,0 +1,63 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 44px;
min-height: 48px;
padding: 0 22px;
border: 1.5px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1;
cursor: pointer;
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
}
.button:active:not(:disabled) {
transform: scale(0.97);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.primary {
background: var(--color-accent);
color: var(--on-accent);
}
.primary:hover:not(:disabled),
.primary:active:not(:disabled) {
background: var(--color-accent-800);
}
.outline {
background: transparent;
border-color: var(--color-neutral-300);
color: var(--color-text);
}
.outline:hover:not(:disabled),
.outline:active:not(:disabled) {
background: var(--color-neutral-200);
}
.dark {
background: var(--color-text);
color: var(--color-bg);
}
.dark:hover:not(:disabled),
.dark:active:not(:disabled) {
background: var(--color-neutral-800);
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Button } from './Button.tsx'
import styles from './Button.module.css'
describe('Button', () => {
it('is a button that defaults to type="button" and the primary variant', () => {
render(<Button>Save changes</Button>)
const button = screen.getByRole('button', { name: 'Save changes' })
expect(button).toHaveAttribute('type', 'button')
expect(button).toHaveClass(styles.button!, styles.primary!)
})
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
render(<Button variant={variant}>Go</Button>)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toHaveClass(styles[variant]!)
expectMinHitArea(button)
})
it('declares the 100 ms pressed scale transition', () => {
render(<Button>Go</Button>)
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
})
it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(
<Button disabled onClick={onClick}>
Go
</Button>,
)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toBeDisabled()
fireEvent.click(button)
expect(onClick).not.toHaveBeenCalled()
})
it('forwards the ref, rest props and an explicit type', () => {
const ref = createRef<HTMLButtonElement>()
render(
<Button ref={ref} type="submit" aria-describedby="hint">
Send
</Button>,
)
const button = screen.getByRole('button', { name: 'Send' })
expect(ref.current).toBe(button)
expect(button).toHaveAttribute('type', 'submit')
expect(button).toHaveAttribute('aria-describedby', 'hint')
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Button.module.css'
export type ButtonVariant = 'primary' | 'outline' | 'dark'
export type ButtonProps = ComponentPropsWithRef<'button'> & {
variant?: ButtonVariant
}
// Pill button. Primary is grape with --on-accent text; outline and dark are the
// secondary treatments from the handoff. `type` defaults to "button" so a
// Button inside a form never submits by accident.
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
return <button type={type} className={classes} {...rest} />
}
+13
View File
@@ -0,0 +1,13 @@
.card {
padding: var(--space-4);
border-radius: var(--radius-lg);
color: var(--color-text);
}
.surface {
background: var(--color-surface);
}
.muted {
background: var(--color-neutral-100);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Card } from './Card.tsx'
import styles from './Card.module.css'
describe('Card', () => {
it('renders its children on the surface fill by default', () => {
render(<Card aria-label="Tonight">Tacos</Card>)
const card = screen.getByLabelText('Tonight')
expect(card).toHaveTextContent('Tacos')
expect(card).toHaveClass(styles.card!, styles.surface!)
})
it('supports the muted tone', () => {
render(<Card data-testid="card" tone="muted" />)
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Card.module.css'
export type CardTone = 'surface' | 'muted'
export type CardProps = ComponentPropsWithRef<'div'> & {
// surface = --color-surface; muted = the secondary neutral-100 fill.
tone?: CardTone
}
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
return <div className={classes} {...rest} />
}
+63
View File
@@ -0,0 +1,63 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-width: 44px;
min-height: 44px;
cursor: pointer;
}
.input {
appearance: none;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
margin: 0;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
}
/* The check mark. */
.input::before {
content: "";
width: 12px;
height: 12px;
background: var(--on-accent);
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
opacity: 0;
transform: scale(0.8);
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
}
.input:checked {
border-color: var(--color-accent);
background: var(--color-accent);
}
.input:checked::before {
opacity: 1;
transform: scale(1);
}
.input:disabled,
.input:disabled + .label {
opacity: 0.45;
cursor: not-allowed;
}
.label {
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
@media (prefers-reduced-motion: reduce) {
.input::before {
transition: none;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Checkbox } from './Checkbox.tsx'
describe('Checkbox', () => {
it('is a native checkbox named by its label', () => {
render(<Checkbox label="Chicken thighs" />)
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
expect(box.tagName).toBe('INPUT')
expect(box).toHaveAttribute('type', 'checkbox')
expect(box).not.toBeChecked()
})
it('toggles when the input or its label text is clicked', () => {
render(<Checkbox label="Limes" />)
const box = screen.getByRole('checkbox', { name: 'Limes' })
fireEvent.click(box)
expect(box).toBeChecked()
fireEvent.click(screen.getByText('Limes'))
expect(box).not.toBeChecked()
})
it('reflects a controlled checked state and disabled', () => {
render(<Checkbox label="Rice" checked disabled readOnly />)
const box = screen.getByRole('checkbox', { name: 'Rice' })
expect(box).toBeChecked()
expect(box).toBeDisabled()
})
it('has a ≥ 44 px hit area (the wrapping label)', () => {
render(<Checkbox label="Rice" />)
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
expectMinHitArea(hit)
})
})
+17
View File
@@ -0,0 +1,17 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import styles from './Checkbox.module.css'
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
label: ReactNode
}
// Native checkbox, styled. The wrapping label is the hit area (at least
// 44×44), so tapping the text toggles it too.
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
return (
<label className={[styles.root, className].filter(Boolean).join(' ')}>
<input type="checkbox" className={styles.input} {...rest} />
<span className={styles.label}>{label}</span>
</label>
)
}
+35
View File
@@ -0,0 +1,35 @@
.chip {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-200);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.removable {
padding-right: 0;
}
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
tall and the cross close to the label. */
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -4px 0 -4px -14px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
+26
View File
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Chip } from './Chip.tsx'
describe('Chip', () => {
it('has no remove button unless onRemove is given', () => {
render(<Chip label="Beef tacos" />)
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
const onRemove = vi.fn()
render(<Chip label="Beef tacos" onRemove={onRemove} />)
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
expect(remove).toHaveAttribute('type', 'button')
fireEvent.click(remove)
expect(onRemove).toHaveBeenCalledOnce()
})
it('gives the remove button a ≥ 44 px hit area', () => {
render(<Chip label="Pizza night" onRemove={() => {}} />)
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Chip.module.css'
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
label: string
// When set, the chip shows a "Remove {label}" button that calls it.
onRemove?: () => void
}
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
return (
<span className={classes} {...rest}>
{label}
{onRemove && (
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
</svg>
</button>
)}
</span>
)
}
+74
View File
@@ -0,0 +1,74 @@
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.size40 {
width: 44px;
height: 44px;
margin: -2px;
}
.size62 {
width: 62px;
height: 62px;
}
.face {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
transition: transform 100ms var(--ease-out-soft);
}
.size40 .face {
width: 40px;
height: 40px;
}
.size62 .face {
width: 62px;
height: 62px;
box-shadow: var(--shadow-md);
}
.button:active:not(:disabled) .face {
transform: scale(0.97);
}
.surface {
background: var(--color-surface);
color: var(--color-text);
}
.accent {
background: var(--color-accent);
color: var(--on-accent);
}
.teal {
background: var(--color-accent-2-600);
color: var(--on-accent);
}
@media (prefers-reduced-motion: reduce) {
.face {
transition: none;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
describe('IconButton', () => {
it('is named by its label, not its icon', () => {
const onClick = vi.fn()
render(
<IconButton label="Notifications" onClick={onClick}>
<svg />
</IconButton>,
)
const button = screen.getByRole('button', { name: 'Notifications' })
expect(button).toHaveAttribute('type', 'button')
fireEvent.click(button)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a 44 px hit area around the 40 px circle', () => {
render(<IconButton label="Back" size={40} />)
const button = screen.getByRole('button', { name: 'Back' })
expectMinHitArea(button)
const face = button.firstElementChild!
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 62 px at the large size', () => {
render(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
}
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
// the 62 px size is the round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
return (
<button type={type} aria-label={label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
</button>
)
}
+69
View File
@@ -0,0 +1,69 @@
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 44px;
padding: 14px 16px;
color: var(--color-text);
text-align: left;
}
.interactive {
padding: 0;
border-radius: var(--radius-md);
}
.interactive:hover {
background: var(--color-neutral-200);
}
.main {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1 1 auto;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.interactive > .trailing {
padding-right: 16px;
}
.leading,
.trailing {
display: inline-flex;
align-items: center;
flex: none;
}
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.title {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.meta {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
}
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { ListRow } from './ListRow.tsx'
import { Toggle } from './Toggle.tsx'
describe('ListRow', () => {
it('renders leading, title, meta and trailing slots without being interactive', () => {
render(
<ListRow
leading={<span>icon</span>}
title="Notifications"
meta="Push and Google Chat"
trailing={<span>chevron</span>}
/>,
)
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
expect(screen.getByText(text)).toBeInTheDocument()
}
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
const onClick = vi.fn()
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
expect(row).toHaveAttribute('type', 'button')
expectMinHitArea(row)
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
const onClick = vi.fn()
const onCheckedChange = vi.fn()
render(
<ListRow
title="Dinner reminders"
meta="Push at 4 pm"
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
onClick={onClick}
/>,
)
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
expect(row).not.toContainElement(toggle)
expect(row.querySelector('button')).toBeNull()
fireEvent.click(toggle)
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
expect(onClick).not.toHaveBeenCalled()
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
expect(onCheckedChange).toHaveBeenCalledOnce()
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import styles from './ListRow.module.css'
export type ListRowProps = {
title: ReactNode
meta?: ReactNode
// Leading slot: icon, photo or avatar.
leading?: ReactNode
// Trailing slot: chevron, toggle, count or action.
trailing?: ReactNode
// When set, the leading slot, title and meta form one button. The trailing
// slot stays a sibling outside it, so a trailing toggle or action is never
// nested in the row's button and never triggers the row's action.
onClick?: () => void
className?: string
}
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
const main = (
<>
{leading && <span className={styles.leading}>{leading}</span>}
<span className={styles.body}>
<span className={styles.title}>{title}</span>
{meta && <span className={styles.meta}>{meta}</span>}
</span>
</>
)
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
if (onClick) {
return (
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
<button type="button" className={styles.main} onClick={onClick}>
{main}
</button>
{trailingSlot}
</div>
)
}
return (
<div className={[styles.row, className].filter(Boolean).join(' ')}>
{main}
{trailingSlot}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
.track {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.option {
flex: 1 1 0;
min-width: 44px;
min-height: 44px;
padding: 0 var(--space-4);
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
}
.selected {
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Segmented } from './Segmented.tsx'
import styles from './Segmented.module.css'
const options = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const
type Period = (typeof options)[number]['value']
function Harness({ initial = 'week' }: { initial?: Period }) {
const [value, setValue] = useState<Period>(initial)
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
}
function radio(name: string) {
return screen.getByRole('radio', { name })
}
describe('Segmented', () => {
it('is a named radiogroup of radios with one checked', () => {
render(<Harness />)
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
expect(screen.getAllByRole('radio')).toHaveLength(3)
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
expect(radio('Week')).toHaveClass(styles.selected!)
})
it('has a single tab stop on the selected option', () => {
render(<Harness initial="month" />)
expect(radio('Month')).toHaveAttribute('tabindex', '0')
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
})
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
render(<Harness />)
radio('Week').focus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveFocus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
})
it('jumps to the first and last option with Home and End', () => {
render(<Harness initial="month" />)
fireEvent.keyDown(radio('Month'), { key: 'End' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'Home' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
})
it('selects an option on click', () => {
render(<Harness />)
fireEvent.click(radio('Year'))
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
})
it('gives every option a ≥ 44 px hit area', () => {
render(<Harness />)
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
it('fails fast when the value is not one of the options', () => {
expect(() =>
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
).toThrow('value "decade" is not one of its options')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { useRef, type KeyboardEvent } from 'react'
import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
value: T
label: string
}
export type SegmentedProps<T extends string> = {
// Accessible name of the radiogroup.
label: string
options: readonly SegmentedOption<T>[]
value: T
onValueChange: (value: T) => void
className?: string
}
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
// Segmented control with radiogroup semantics: one tab stop (the selected
// option); arrow keys move the selection and focus, wrapping at the ends;
// Home and End jump to the first and last option.
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
const refs = useRef<(HTMLButtonElement | null)[]>([])
const selected = options.findIndex((option) => option.value === value)
if (selected === -1) {
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
}
function select(index: number) {
const option = options[index]
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
onValueChange(option.value)
refs.current[index]?.focus()
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + options.length) % options.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = options.length - 1
else return
event.preventDefault()
select(next)
}
return (
<div
role="radiogroup"
aria-label={label}
className={[styles.track, className].filter(Boolean).join(' ')}
onKeyDown={onKeyDown}
>
{options.map((option, index) => {
const checked = index === selected
return (
<button
key={option.value}
ref={(el) => {
refs.current[index] = el
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
onClick={() => select(index)}
>
{option.label}
</button>
)
})}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
/* The input is the 44 px tall hit area; the track and knob are drawn by the
vendor pseudo-elements. --slider-fill is set inline from the value. */
.slider {
appearance: none;
display: block;
width: 100%;
min-width: 44px;
height: 44px;
margin: 0;
background: transparent;
cursor: pointer;
}
.slider:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.slider::-webkit-slider-runnable-track {
height: 8px;
border-radius: var(--radius-pill);
background: linear-gradient(
to right,
var(--color-accent) var(--slider-fill),
var(--color-neutral-300) var(--slider-fill)
);
}
.slider::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: 24px;
height: 24px;
margin-top: -8px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.slider::-moz-range-track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.slider::-moz-range-progress {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.slider::-moz-range-thumb {
box-sizing: border-box;
width: 24px;
height: 24px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
+69
View File
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Slider } from './Slider.tsx'
function Harness() {
const [value, setValue] = useState(0.7)
return (
<Slider
aria-label="Creativity (temperature)"
min={0}
max={1}
step={0.1}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
)
}
describe('Slider', () => {
it('is a native slider exposing its range and value', () => {
render(<Harness />)
const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
expect(slider).toHaveAttribute('type', 'range')
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '1')
expect(slider).toHaveValue('0.7')
})
it('updates the grape fill with the value', () => {
render(<Harness />)
const slider = screen.getByRole('slider')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
fireEvent.change(slider, { target: { value: '0.2' } })
expect(slider).toHaveValue('0.2')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
})
it('has a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(screen.getByRole('slider'))
})
it('fails fast on an empty range', () => {
expect(() => render(<Slider aria-label="Bad" min={1} max={1} value={1} readOnly />)).toThrow(
'max (1) must be greater than min (1)',
)
})
it('accepts values at both ends of the range', () => {
render(
<>
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
</>,
)
expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%')
expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%')
})
it.each([
[2, 'value (2) must be within [0, 1]'],
[-0.1, 'value (-0.1) must be within [0, 1]'],
[Number.NaN, 'value (NaN) must be within [0, 1]'],
])('fails fast when the value %s is outside the range', (value, message) => {
expect(() => render(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { CSSProperties, ComponentPropsWithRef } from 'react'
import styles from './Slider.module.css'
export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value' | 'min' | 'max'> & {
value: number
min: number
max: number
}
// Native range input, styled: 8 px track, grape fill up to the value, 24 px
// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
// Name it with aria-label or aria-labelledby.
export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
if (!(value >= min && value <= max)) {
throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`)
}
const fill = `${((value - min) / (max - min)) * 100}%`
return (
<input
type="range"
value={value}
min={min}
max={max}
className={[styles.slider, className].filter(Boolean).join(' ')}
style={{ ...style, '--slider-fill': fill } as CSSProperties}
{...rest}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.grape {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.teal {
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Tag } from './Tag.tsx'
import styles from './Tag.module.css'
describe('Tag', () => {
it('renders a static, non-interactive label', () => {
render(<Tag>Draft</Tag>)
const tag = screen.getByText('Draft')
expect(tag).toHaveClass(styles.grape!)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('supports the teal tone', () => {
render(<Tag tone="teal">Synced</Tag>)
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal'
export type TagProps = ComponentPropsWithRef<'span'> & {
tone?: TagTone
}
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} />
}
+46
View File
@@ -0,0 +1,46 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.input {
box-sizing: border-box;
min-width: 44px;
height: 48px;
padding: 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 16px;
}
.input::placeholder {
color: var(--color-neutral-700);
}
.input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { TextInput } from './TextInput.tsx'
describe('TextInput', () => {
it('is a textbox named by its label', () => {
const onChange = vi.fn()
render(<TextInput label="Dinner name" placeholder="e.g. Chicken enchiladas" onChange={onChange} />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
fireEvent.change(input, { target: { value: 'Chili' } })
expect(onChange).toHaveBeenCalledOnce()
expect(input).toHaveValue('Chili')
})
it('keeps the accessible name when the label is visually hidden', () => {
render(<TextInput label="Add a regular" labelHidden />)
expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
})
it('is a 48 px pill with a ≥ 44 px hit area', () => {
render(<TextInput label="Dinner name" />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
expectMinHitArea(input)
expect(hitArea(input).height).toBe(48)
})
it('forwards the ref', () => {
const ref = createRef<HTMLInputElement>()
render(<TextInput label="Dinner name" ref={ref} />)
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
})
})
+21
View File
@@ -0,0 +1,21 @@
import { useId, type ComponentPropsWithRef } from 'react'
import styles from './TextInput.module.css'
export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'id'> & {
label: string
// Hide the label visually; it stays the input's accessible name.
labelHidden?: boolean
}
// 48 px pill input on the surface fill, always associated with its label.
export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
const id = useId()
return (
<div className={styles.field}>
<label htmlFor={id} className={labelHidden ? styles.hiddenLabel : styles.label}>
{label}
</label>
<input id={id} type={type} className={[styles.input, className].filter(Boolean).join(' ')} {...rest} />
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
/* The button is a 44 px hit box around the 40×24 track; negative margins keep
the 40×24 layout footprint. */
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -10px -2px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.track {
position: relative;
width: 40px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
transition: background-color 150ms var(--ease-out-soft);
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform 180ms var(--ease-spring);
}
.toggle[aria-checked='true'] .track {
background: var(--color-accent-2-600);
}
.toggle[aria-checked='true'] .knob {
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.track,
.knob {
transition: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { Toggle } from './Toggle.tsx'
function Harness() {
const [checked, setChecked] = useState(false)
return <Toggle aria-label="Push: New weekly plan ready" checked={checked} onCheckedChange={setChecked} />
}
function toggle() {
return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
}
describe('Toggle', () => {
it('is a switch reflecting its state in aria-checked', () => {
render(<Harness />)
expect(toggle()).toHaveAttribute('aria-checked', 'false')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
render(<Harness />)
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it('suppresses native activation for Space and Enter so a key press toggles once', () => {
render(<Harness />)
expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
})
it('ignores other keys', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} onCheckedChange={onCheckedChange} />)
fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('is disabled when asked', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} disabled onCheckedChange={onCheckedChange} />)
const sw = screen.getByRole('switch', { name: 'Chat' })
expect(sw).toBeDisabled()
fireEvent.click(sw)
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(toggle())
expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
import styles from './Toggle.module.css'
export type ToggleProps = Omit<
ComponentPropsWithRef<'button'>,
'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
> & {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
// 40×24 pill switch (role="switch"). Name it with aria-label or
// aria-labelledby. Space and Enter are handled here and their native button
// activation is suppressed, so each key press toggles exactly once.
export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key !== ' ' && event.key !== 'Enter') return
event.preventDefault()
onCheckedChange(!checked)
}
function onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === ' ') event.preventDefault()
}
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={[styles.toggle, className].filter(Boolean).join(' ')}
onClick={() => onCheckedChange(!checked)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
{...rest}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.knob} />
</span>
</button>
)
}
+11
View File
@@ -0,0 +1,11 @@
export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
export { Card, type CardProps, type CardTone } from './Card.tsx'
export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
export { Chip, type ChipProps } from './Chip.tsx'
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
export { TextInput, type TextInputProps } from './TextInput.tsx'
export { Toggle, type ToggleProps } from './Toggle.tsx'
+3
View File
@@ -14,6 +14,9 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
// Inject CSS Modules into jsdom so tests can assert the sizes the
// stylesheets actually declare (for example 44 px hit areas).
css: true,
setupFiles: ['./src/test/setup.ts'],
},
})