diff --git a/web/src/test/hitArea.ts b/web/src/test/hitArea.ts new file mode 100644 index 0000000..fa0f903 --- /dev/null +++ b/web/src/test/hitArea.ts @@ -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) +} diff --git a/web/src/ui/Button.module.css b/web/src/ui/Button.module.css new file mode 100644 index 0000000..8b9de26 --- /dev/null +++ b/web/src/ui/Button.module.css @@ -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; + } +} diff --git a/web/src/ui/Button.test.tsx b/web/src/ui/Button.test.tsx new file mode 100644 index 0000000..4797e88 --- /dev/null +++ b/web/src/ui/Button.test.tsx @@ -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() + 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() + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toHaveClass(styles[variant]!) + expectMinHitArea(button) + }) + + it('declares the 100 ms pressed scale transition', () => { + render() + expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms') + }) + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn() + render( + , + ) + 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() + render( + , + ) + const button = screen.getByRole('button', { name: 'Send' }) + expect(ref.current).toBe(button) + expect(button).toHaveAttribute('type', 'submit') + expect(button).toHaveAttribute('aria-describedby', 'hint') + }) +}) diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..4a58825 --- /dev/null +++ b/web/src/ui/Button.tsx @@ -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 + )} + + ) +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css new file mode 100644 index 0000000..7dd0c66 --- /dev/null +++ b/web/src/ui/IconButton.module.css @@ -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; + } +} diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx new file mode 100644 index 0000000..0341c1a --- /dev/null +++ b/web/src/ui/IconButton.test.tsx @@ -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( + + + , + ) + 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() + 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() + const button = screen.getByRole('button', { name: 'Try it' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 62, height: 62 }) + }) +}) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx new file mode 100644 index 0000000..1a5ab73 --- /dev/null +++ b/web/src/ui/IconButton.tsx @@ -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, '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 ( + + ) +} diff --git a/web/src/ui/ListRow.module.css b/web/src/ui/ListRow.module.css new file mode 100644 index 0000000..f182b5b --- /dev/null +++ b/web/src/ui/ListRow.module.css @@ -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); +} diff --git a/web/src/ui/ListRow.test.tsx b/web/src/ui/ListRow.test.tsx new file mode 100644 index 0000000..f1e9108 --- /dev/null +++ b/web/src/ui/ListRow.test.tsx @@ -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( + icon} + title="Notifications" + meta="Push and Google Chat" + trailing={chevron} + />, + ) + 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() + 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( + } + 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() + }) +}) diff --git a/web/src/ui/ListRow.tsx b/web/src/ui/ListRow.tsx new file mode 100644 index 0000000..8004bf2 --- /dev/null +++ b/web/src/ui/ListRow.tsx @@ -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 && {leading}} + + {title} + {meta && {meta}} + + + ) + const trailingSlot = trailing && {trailing} + if (onClick) { + return ( +
+ + {trailingSlot} +
+ ) + } + return ( +
+ {main} + {trailingSlot} +
+ ) +} diff --git a/web/src/ui/Segmented.module.css b/web/src/ui/Segmented.module.css new file mode 100644 index 0000000..3d94baa --- /dev/null +++ b/web/src/ui/Segmented.module.css @@ -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; + } +} diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx new file mode 100644 index 0000000..c107a1e --- /dev/null +++ b/web/src/ui/Segmented.test.tsx @@ -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(initial) + return +} + +function radio(name: string) { + return screen.getByRole('radio', { name }) +} + +describe('Segmented', () => { + it('is a named radiogroup of radios with one checked', () => { + render() + 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() + 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() + 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() + 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() + 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() + for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) + }) + + it('fails fast when the value is not one of the options', () => { + expect(() => + render( {}} />), + ).toThrow('value "decade" is not one of its options') + }) +}) diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx new file mode 100644 index 0000000..bd53b27 --- /dev/null +++ b/web/src/ui/Segmented.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent } from 'react' +import styles from './Segmented.module.css' + +export type SegmentedOption = { + value: T + label: string +} + +export type SegmentedProps = { + // Accessible name of the radiogroup. + label: string + options: readonly SegmentedOption[] + value: T + onValueChange: (value: T) => void + className?: string +} + +const step: Record = { 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({ label, options, value, onValueChange, className }: SegmentedProps) { + 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) { + 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 ( +
+ {options.map((option, index) => { + const checked = index === selected + return ( + + ) + })} +
+ ) +} diff --git a/web/src/ui/Slider.module.css b/web/src/ui/Slider.module.css new file mode 100644 index 0000000..a557abf --- /dev/null +++ b/web/src/ui/Slider.module.css @@ -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); +} diff --git a/web/src/ui/Slider.test.tsx b/web/src/ui/Slider.test.tsx new file mode 100644 index 0000000..ce95efb --- /dev/null +++ b/web/src/ui/Slider.test.tsx @@ -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 ( + setValue(Number(event.target.value))} + /> + ) +} + +describe('Slider', () => { + it('is a native slider exposing its range and value', () => { + render() + 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() + 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() + expectMinHitArea(screen.getByRole('slider')) + }) + + it('fails fast on an empty range', () => { + expect(() => render()).toThrow( + 'max (1) must be greater than min (1)', + ) + }) + + it('accepts values at both ends of the range', () => { + render( + <> + + + , + ) + 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()).toThrow(message) + }) +}) diff --git a/web/src/ui/Slider.tsx b/web/src/ui/Slider.tsx new file mode 100644 index 0000000..af430c8 --- /dev/null +++ b/web/src/ui/Slider.tsx @@ -0,0 +1,30 @@ +import type { CSSProperties, ComponentPropsWithRef } from 'react' +import styles from './Slider.module.css' + +export type SliderProps = Omit, '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 ( + + ) +} diff --git a/web/src/ui/Tag.module.css b/web/src/ui/Tag.module.css new file mode 100644 index 0000000..a3df5e4 --- /dev/null +++ b/web/src/ui/Tag.module.css @@ -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); +} diff --git a/web/src/ui/Tag.test.tsx b/web/src/ui/Tag.test.tsx new file mode 100644 index 0000000..8c6f795 --- /dev/null +++ b/web/src/ui/Tag.test.tsx @@ -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(Draft) + const tag = screen.getByText('Draft') + expect(tag).toHaveClass(styles.grape!) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('supports the teal tone', () => { + render(Synced) + expect(screen.getByText('Synced')).toHaveClass(styles.teal!) + }) +}) diff --git a/web/src/ui/Tag.tsx b/web/src/ui/Tag.tsx new file mode 100644 index 0000000..5d26c95 --- /dev/null +++ b/web/src/ui/Tag.tsx @@ -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 +} diff --git a/web/src/ui/TextInput.module.css b/web/src/ui/TextInput.module.css new file mode 100644 index 0000000..e44b927 --- /dev/null +++ b/web/src/ui/TextInput.module.css @@ -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; +} diff --git a/web/src/ui/TextInput.test.tsx b/web/src/ui/TextInput.test.tsx new file mode 100644 index 0000000..f4c7a6b --- /dev/null +++ b/web/src/ui/TextInput.test.tsx @@ -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() + 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() + expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument() + }) + + it('is a 48 px pill with a ≥ 44 px hit area', () => { + render() + const input = screen.getByRole('textbox', { name: 'Dinner name' }) + expectMinHitArea(input) + expect(hitArea(input).height).toBe(48) + }) + + it('forwards the ref', () => { + const ref = createRef() + render() + expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' })) + }) +}) diff --git a/web/src/ui/TextInput.tsx b/web/src/ui/TextInput.tsx new file mode 100644 index 0000000..fd6ea01 --- /dev/null +++ b/web/src/ui/TextInput.tsx @@ -0,0 +1,21 @@ +import { useId, type ComponentPropsWithRef } from 'react' +import styles from './TextInput.module.css' + +export type TextInputProps = Omit, '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 ( +
+ + +
+ ) +} diff --git a/web/src/ui/Toggle.module.css b/web/src/ui/Toggle.module.css new file mode 100644 index 0000000..ddcd414 --- /dev/null +++ b/web/src/ui/Toggle.module.css @@ -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; + } +} diff --git a/web/src/ui/Toggle.test.tsx b/web/src/ui/Toggle.test.tsx new file mode 100644 index 0000000..8237227 --- /dev/null +++ b/web/src/ui/Toggle.test.tsx @@ -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 +} + +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() + 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() + 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() + 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() + fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' }) + expect(onCheckedChange).not.toHaveBeenCalled() + }) + + it('is disabled when asked', () => { + const onCheckedChange = vi.fn() + render() + 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() + expectMinHitArea(toggle()) + expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 }) + }) +}) diff --git a/web/src/ui/Toggle.tsx b/web/src/ui/Toggle.tsx new file mode 100644 index 0000000..e45979b --- /dev/null +++ b/web/src/ui/Toggle.tsx @@ -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) { + if (event.key !== ' ' && event.key !== 'Enter') return + event.preventDefault() + onCheckedChange(!checked) + } + + function onKeyUp(event: KeyboardEvent) { + if (event.key === ' ') event.preventDefault() + } + + return ( + + ) +} diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts new file mode 100644 index 0000000..f0e6b63 --- /dev/null +++ b/web/src/ui/index.ts @@ -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' diff --git a/web/vite.config.ts b/web/vite.config.ts index 1a9192b..09250bb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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'], }, })