Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 ef3e7d0e45 Leave controls disabled by a fieldset out of the Sheet focus trap
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m28s
CI / ci (pull_request) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:17:15 -04:00
dev_agentandClaude Opus 5.5 0fa9cfe611 Move Sheet focus only to controls that can take keyboard focus
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m29s
Review finding MQ-36-01: a hidden first button was chosen as the initial
focus target and as a Tab trap stop, so focus never entered the dialog.
Filter out controls with a negative tabIndex, a hidden/aria-hidden/inert
ancestor, display: none or visibility: hidden; on open, skip any control
whose focus() does not take, falling back to the dialog panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:14:15 -04:00
dev_agentandClaude Opus 5.5 1dd97b123c Add Sheet, ErrorBanner, Skeleton and EmptyState primitives
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high)
CI / ci (pull_request) Successful in 1m28s
CI / ci (push) Successful in 1m28s
Sheet is a portalled modal bottom sheet: role="dialog" with aria-modal
and a required accessible name, focus moved in, trapped and restored on
close, Escape and scrim click call onClose, and the rest of <body> is
inert while open. It rises from translateY(100%) over 320 ms
var(--ease-out-soft) over a scrim fading to 50% neutral-900; under
prefers-reduced-motion it opens in place.

ErrorBanner is a muted neutral-100 Card with grape-700 text and a Retry
Button; Skeleton is a --color-surface pill with a 1.2 s opacity pulse
switched off under reduced motion; EmptyState has a Bagel title, body
and an optional primary Button action.

jsdom never matches prefers-reduced-motion, resolves var() colours to
transparent and does not expand the animation shorthand, so the new
src/test/cssRules.ts reads the CSS Modules' declared rules, the
reduced-motion media block and the hashed @keyframes directly.

Maestro task: 2f9b64dd-0c26-47b9-b2b8-557bdf506d68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:09:47 -04:00
dev_agent 02f1dd6491 Feature 05 task 2: Core UI primitives (#34)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:34:59 -04:00
dev_agent 496f2e5375 Feature 05 task 1: Design tokens and self-hosted fonts (#32)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:16:25 -04:00
49 changed files with 2137 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
// (css: true). jsdom's getComputedStyle cannot answer two things these
// primitives need checked: it resolves var() colours to transparent, and it
// evaluates only "all" and "screen" media queries, so styles inside
// @media (prefers-reduced-motion: reduce) never apply.
function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
}
}
// The declarations the stylesheets give the element, as written. Read the
// shorthands the CSS uses (background, animation): jsdom does not expand
// animation, nor any shorthand holding a var(). Later rules win; specificity
// is not considered.
export function declaredStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
collect(element, cssRules(), style)
return style
}
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
for (const rule of cssRules()) {
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
collect(element, Array.from(rule.cssRules), style)
}
}
return style
}
// The @keyframes the element's animation runs. CSS Modules hash keyframe
// names, so this follows the element's own animation-name.
export function animationKeyframes(element: Element): CSSKeyframesRule {
const [name] = declaredStyle(element).animation.split(' ')
const keyframes = cssRules().find(
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
)
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
return keyframes
}
+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>
)
}
+30
View File
@@ -0,0 +1,30 @@
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-8) var(--space-4);
color: var(--color-text);
text-align: center;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.15;
}
.body {
margin: 0;
max-width: 32ch;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.action {
margin-top: var(--space-2);
}
+27
View File
@@ -0,0 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { EmptyState } from './EmptyState.tsx'
describe('EmptyState', () => {
it('shows a Bagel title and body without an action by default', () => {
render(<EmptyState title="Quest log is empty">Swipe Try it on a Side quest to save it here.</EmptyState>)
const title = screen.getByRole('heading', { name: 'Quest log is empty' })
expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders the optional primary action and calls its handler', () => {
const onClick = vi.fn()
render(
<EmptyState title="No Regulars yet" action={{ label: 'Add a dinner', onClick }}>
Add the dinners you make most.
</EmptyState>,
)
const action = screen.getByRole('button', { name: 'Add a dinner' })
expectMinHitArea(action)
fireEvent.click(action)
expect(onClick).toHaveBeenCalledTimes(1)
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import styles from './EmptyState.module.css'
export type EmptyStateAction = {
label: string
onClick: () => void
}
export type EmptyStateProps = {
title: string
children: ReactNode
// Optional primary action, rendered as a primary Button.
action?: EmptyStateAction
}
// Bagel title, body text and an optional primary action.
export function EmptyState({ title, children, action }: EmptyStateProps) {
return (
<section className={styles.empty}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.body}>{children}</p>
{action && (
<Button className={styles.action} onClick={action.onClick}>
{action.label}
</Button>
)}
</section>
)
}
+16
View File
@@ -0,0 +1,16 @@
.banner {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-neutral-100);
}
.message {
flex: 1;
margin: 0;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+32
View File
@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { declaredStyle } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { ErrorBanner } from './ErrorBanner.tsx'
describe('ErrorBanner', () => {
it('announces the message and calls onRetry from the Retry button', () => {
const onRetry = vi.fn()
render(<ErrorBanner onRetry={onRetry}>Couldn't load the Supply run.</ErrorBanner>)
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
const retry = screen.getByRole('button', { name: 'Retry' })
expectMinHitArea(retry)
fireEvent.click(retry)
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('uses a caller-supplied retry label', () => {
render(
<ErrorBanner onRetry={() => {}} retryLabel="Rebuild week">
The plan didn't save.
</ErrorBanner>,
)
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
})
it('is a neutral-100 card with grape-700 text', () => {
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import { Card } from './Card.tsx'
import styles from './ErrorBanner.module.css'
export type ErrorBannerProps = {
// What went wrong, in the caller's words.
children: ReactNode
onRetry: () => void
retryLabel?: string
}
// Muted neutral-100 card with grape-700 text and a Retry button.
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
return (
<Card tone="muted" role="alert" className={styles.banner}>
<p className={styles.message}>{children}</p>
<Button onClick={onRetry}>{retryLabel}</Button>
</Card>
)
}
+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>
)
}
+57
View File
@@ -0,0 +1,57 @@
.root {
position: fixed;
inset: 0;
z-index: 100;
}
.scrim {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
animation: fade 320ms var(--ease-out-soft);
}
.panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: 90dvh;
overflow-y: auto;
padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-lg);
transform: translateY(0);
animation: rise 320ms var(--ease-out-soft);
}
.panel:focus {
outline: none;
}
@keyframes rise {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.scrim,
.panel {
animation: none;
}
}
+267
View File
@@ -0,0 +1,267 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import '../styles/tokens.css'
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
import { Sheet } from './Sheet.tsx'
import styles from './Sheet.module.css'
function Harness({ withControls = true }: { withControls?: boolean }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open Supply run
</button>
<Sheet aria-label="Supply run" open={open} onClose={() => setOpen(false)}>
<p>Grouped by dinner</p>
{withControls && (
<>
<button type="button">Try it</button>
<button type="button">Pass</button>
</>
)}
</Sheet>
</>
)
}
function openSheet() {
const opener = screen.getByRole('button', { name: 'Open Supply run' })
opener.focus()
fireEvent.click(opener)
return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
}
function scrim() {
const element = document.querySelector(`.${styles.scrim}`)
if (!element) throw new Error('scrim not rendered')
return element
}
describe('Sheet', () => {
it('renders nothing while closed', () => {
render(<Harness />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('is a labelled modal dialog', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
it('accepts aria-labelledby as its accessible name', () => {
render(
<>
<h2 id="sheet-title">Quest log</h2>
<Sheet aria-labelledby="sheet-title" open onClose={() => {}}>
<p>Nothing yet</p>
</Sheet>
</>,
)
expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
})
it('moves focus to the first control and restores it to the opener on close', () => {
render(<Harness />)
const { opener } = openSheet()
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(opener).toHaveFocus()
})
it('focuses the dialog itself when it has no focusable control', () => {
render(<Harness withControls={false} />)
const { dialog } = openSheet()
expect(dialog).toHaveFocus()
})
it('skips controls that cannot take keyboard focus when moving focus in', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden
</button>
<button type="button" style={{ display: 'none' }}>
Not displayed
</button>
<button type="button" style={{ visibility: 'hidden' }}>
Invisible
</button>
<button type="button" tabIndex={-1}>
Not tabbable
</button>
<div aria-hidden="true">
<button type="button">Aria hidden</button>
</div>
<button type="button">Try it</button>
</Sheet>,
)
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
})
it('focuses the dialog itself when every control is hidden', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden
</button>
</Sheet>,
)
expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus()
})
it('does not stop the Tab trap on a hidden control', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden first
</button>
<button type="button">Try it</button>
<button type="button">Pass</button>
<button type="button" hidden>
Hidden last
</button>
</Sheet>,
)
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('does not stop the Tab trap on a control disabled by its fieldset', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<fieldset disabled>
<button type="button">Disabled first</button>
</fieldset>
<button type="button">Try it</button>
<button type="button">Pass</button>
<fieldset disabled>
<button type="button">Disabled last</button>
</fieldset>
</Sheet>,
)
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
expect(first).toHaveFocus()
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('wraps Tab and Shift+Tab inside the sheet', () => {
render(<Harness />)
openSheet()
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('keeps focus on the dialog when Tab has nowhere to go', () => {
render(<Harness withControls={false} />)
const { dialog } = openSheet()
const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
expect(tab).toBe(false) // default prevented
expect(dialog).toHaveFocus()
})
it('closes on Escape', () => {
const onClose = vi.fn()
render(
<Sheet aria-label="Supply run" open onClose={onClose}>
<button type="button">Try it</button>
</Sheet>,
)
fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('closes on a scrim click but not on a panel click', () => {
const onClose = vi.fn()
render(
<Sheet aria-label="Supply run" open onClose={onClose}>
<p>Grouped by dinner</p>
</Sheet>,
)
fireEvent.click(screen.getByRole('dialog'))
fireEvent.click(screen.getByText('Grouped by dinner'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(scrim())
expect(onClose).toHaveBeenCalledTimes(1)
})
it('makes the background inert while open and restores it on close', () => {
const { container } = render(<Harness />)
openSheet()
const root = screen.getByRole('dialog').parentElement!
expect(root.parentElement).toBe(document.body)
expect(container).toHaveAttribute('inert')
expect(root).not.toHaveAttribute('inert')
fireEvent.click(scrim())
expect(container).not.toHaveAttribute('inert')
})
it('leaves an already-inert background element inert after close', () => {
const other = document.createElement('div')
other.setAttribute('inert', '')
document.body.append(other)
try {
render(<Harness />)
openSheet()
fireEvent.click(scrim())
expect(other).toHaveAttribute('inert')
} finally {
other.remove()
}
})
it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
const rise = animationKeyframes(dialog)
expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
})
it('fades the scrim in to 50% neutral-900', () => {
render(<Harness />)
openSheet()
const style = declaredStyle(scrim())
expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
const fade = animationKeyframes(scrim())
expect(fade.findRule('from')?.style.opacity).toBe('0')
expect(fade.findRule('to')?.style.opacity).toBe('1')
})
it('opens in place with no rise or fade under reduced motion', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(reducedMotionStyle(dialog).animation).toBe('none')
expect(reducedMotionStyle(scrim()).animation).toBe('none')
expect(declaredStyle(dialog).transform).toBe('translateY(0)')
})
})
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './Sheet.module.css'
// The dialog's accessible name: exactly one of a label or a labelling element.
type SheetName =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-labelledby': string; 'aria-label'?: never }
export type SheetProps = SheetName & {
open: boolean
onClose: () => void
children: ReactNode
}
const focusableSelector = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[tabindex]',
].join(',')
// Whether an element can take keyboard focus: being disabled (by its own
// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden
// or inert ancestor (or self), display: none on the way up to the panel, or
// visibility: hidden all rule it out.
function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean {
if (element.matches(':disabled')) return false
if (element.tabIndex < 0) return false
if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false
if (getComputedStyle(element).visibility === 'hidden') return false
for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) {
if (getComputedStyle(node).display === 'none') return false
}
return true
}
function focusables(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) =>
isKeyboardFocusable(element, panel),
)
}
// Focuses the first control that actually takes focus, or the panel if none does.
function focusFirst(panel: HTMLElement) {
for (const element of focusables(panel)) {
element.focus()
if (document.activeElement === element) return
}
panel.focus()
}
// Modal bottom sheet. Portalled to <body> so everything else there can be made
// inert while it is open. Focus moves in on open, Tab is trapped inside, and
// focus returns to the opener on close. Escape and a scrim click call onClose.
export function Sheet({ open, onClose, children, ...name }: SheetProps) {
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
const panel = panelRef.current
if (!open || !root || !panel) return
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
const background = Array.from(document.body.children).filter(
(element) => element !== root && !element.hasAttribute('inert'),
)
for (const element of background) element.setAttribute('inert', '')
focusFirst(panel)
return () => {
for (const element of background) element.removeAttribute('inert')
opener?.focus()
}
}, [open])
if (!open) return null
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
event.stopPropagation()
onClose()
return
}
if (event.key !== 'Tab' || !panelRef.current) return
const items = focusables(panelRef.current)
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) {
event.preventDefault()
return
}
const active = document.activeElement
if (event.shiftKey && (active === first || active === panelRef.current)) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}
return createPortal(
<div ref={rootRef} className={styles.root}>
<div className={styles.scrim} aria-hidden="true" onClick={onClose} />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={styles.panel}
onKeyDown={onKeyDown}
{...name}
>
{children}
</div>
</div>,
document.body,
)
}
+24
View File
@@ -0,0 +1,24 @@
.skeleton {
display: block;
width: 100%;
height: 16px;
border-radius: var(--radius-pill);
background: var(--color-surface);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
import { Skeleton } from './Skeleton.tsx'
function skeleton(container: HTMLElement) {
return container.firstElementChild as HTMLElement
}
describe('Skeleton', () => {
it('is a hidden pill in --color-surface', () => {
const { container } = render(<Skeleton />)
const element = skeleton(container)
expect(element).toHaveAttribute('aria-hidden', 'true')
const style = declaredStyle(element)
expect(style.borderRadius).toBe('var(--radius-pill)')
expect(style.background).toBe('var(--color-surface)')
})
it('pulses its opacity over 1.2 s', () => {
const { container } = render(<Skeleton />)
expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
})
it('does not pulse under reduced motion', () => {
const { container } = render(<Skeleton />)
expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
})
it('merges a caller className and forwards style', () => {
const { container } = render(<Skeleton className="line" style={{ width: '60%' }} />)
expect(skeleton(container)).toHaveClass('line')
expect(skeleton(container).style.width).toBe('60%')
})
})
+12
View File
@@ -0,0 +1,12 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Skeleton.module.css'
export type SkeletonProps = Omit<ComponentPropsWithRef<'div'>, 'children'>
// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse.
// Full width and 16 px tall by default; size it with className or style.
// Hidden from assistive technology — announce loading on the container.
export function Skeleton({ className, ...rest }: SkeletonProps) {
const classes = [styles.skeleton, className].filter(Boolean).join(' ')
return <div aria-hidden="true" className={classes} {...rest} />
}
+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>
)
}
+15
View File
@@ -0,0 +1,15 @@
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 { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.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 { Sheet, type SheetProps } from './Sheet.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.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'],
},
})