diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts new file mode 100644 index 0000000..e8413aa --- /dev/null +++ b/web/src/test/cssRules.ts @@ -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 +} diff --git a/web/src/ui/EmptyState.module.css b/web/src/ui/EmptyState.module.css new file mode 100644 index 0000000..c9ef4f1 --- /dev/null +++ b/web/src/ui/EmptyState.module.css @@ -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); +} diff --git a/web/src/ui/EmptyState.test.tsx b/web/src/ui/EmptyState.test.tsx new file mode 100644 index 0000000..c861752 --- /dev/null +++ b/web/src/ui/EmptyState.test.tsx @@ -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(Swipe Try it on a Side quest to save it here.) + 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( + + Add the dinners you make most. + , + ) + const action = screen.getByRole('button', { name: 'Add a dinner' }) + expectMinHitArea(action) + fireEvent.click(action) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/ui/EmptyState.tsx b/web/src/ui/EmptyState.tsx new file mode 100644 index 0000000..0c0ecab --- /dev/null +++ b/web/src/ui/EmptyState.tsx @@ -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 ( +
+

{title}

+

{children}

+ {action && ( + + )} +
+ ) +} diff --git a/web/src/ui/ErrorBanner.module.css b/web/src/ui/ErrorBanner.module.css new file mode 100644 index 0000000..e0ffc8d --- /dev/null +++ b/web/src/ui/ErrorBanner.module.css @@ -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; +} diff --git a/web/src/ui/ErrorBanner.test.tsx b/web/src/ui/ErrorBanner.test.tsx new file mode 100644 index 0000000..811b5e5 --- /dev/null +++ b/web/src/ui/ErrorBanner.test.tsx @@ -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(Couldn't load the Supply run.) + 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( + {}} retryLabel="Rebuild week"> + The plan didn't save. + , + ) + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() + }) + + it('is a neutral-100 card with grape-700 text', () => { + render( {}}>Offline) + expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)') + expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)') + }) +}) diff --git a/web/src/ui/ErrorBanner.tsx b/web/src/ui/ErrorBanner.tsx new file mode 100644 index 0000000..1274bf9 --- /dev/null +++ b/web/src/ui/ErrorBanner.tsx @@ -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 ( + +

{children}

+ +
+ ) +} diff --git a/web/src/ui/Sheet.module.css b/web/src/ui/Sheet.module.css new file mode 100644 index 0000000..dcb5cd8 --- /dev/null +++ b/web/src/ui/Sheet.module.css @@ -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; + } +} diff --git a/web/src/ui/Sheet.test.tsx b/web/src/ui/Sheet.test.tsx new file mode 100644 index 0000000..14564b9 --- /dev/null +++ b/web/src/ui/Sheet.test.tsx @@ -0,0 +1,183 @@ +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 ( + <> + + setOpen(false)}> +

Grouped by dinner

+ {withControls && ( + <> + + + + )} +
+ + ) +} + +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() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('is a labelled modal dialog', () => { + render() + const { dialog } = openSheet() + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + it('accepts aria-labelledby as its accessible name', () => { + render( + <> +

Quest log

+ {}}> +

Nothing yet

+
+ , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument() + }) + + it('moves focus to the first control and restores it to the opener on close', () => { + render() + 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() + const { dialog } = openSheet() + expect(dialog).toHaveFocus() + }) + + it('wraps Tab and Shift+Tab inside the sheet', () => { + render() + 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() + 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( + + + , + ) + 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( + +

Grouped by dinner

+
, + ) + 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() + 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() + 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() + 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() + 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() + const { dialog } = openSheet() + expect(reducedMotionStyle(dialog).animation).toBe('none') + expect(reducedMotionStyle(scrim()).animation).toBe('none') + expect(declaredStyle(dialog).transform).toBe('translateY(0)') + }) +}) diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx new file mode 100644 index 0000000..d1db410 --- /dev/null +++ b/web/src/ui/Sheet.tsx @@ -0,0 +1,97 @@ +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:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +function focusables(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll(focusableSelector)) +} + +// Modal bottom sheet. Portalled to 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(null) + const panelRef = useRef(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', '') + ;(focusables(panel)[0] ?? panel).focus() + + return () => { + for (const element of background) element.removeAttribute('inert') + opener?.focus() + } + }, [open]) + + if (!open) return null + + function onKeyDown(event: KeyboardEvent) { + 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( +
+ , + document.body, + ) +} diff --git a/web/src/ui/Skeleton.module.css b/web/src/ui/Skeleton.module.css new file mode 100644 index 0000000..8647c14 --- /dev/null +++ b/web/src/ui/Skeleton.module.css @@ -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; + } +} diff --git a/web/src/ui/Skeleton.test.tsx b/web/src/ui/Skeleton.test.tsx new file mode 100644 index 0000000..a588018 --- /dev/null +++ b/web/src/ui/Skeleton.test.tsx @@ -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() + 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() + 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() + expect(reducedMotionStyle(skeleton(container)).animation).toBe('none') + }) + + it('merges a caller className and forwards style', () => { + const { container } = render() + expect(skeleton(container)).toHaveClass('line') + expect(skeleton(container).style.width).toBe('60%') + }) +}) diff --git a/web/src/ui/Skeleton.tsx b/web/src/ui/Skeleton.tsx new file mode 100644 index 0000000..75e2458 --- /dev/null +++ b/web/src/ui/Skeleton.tsx @@ -0,0 +1,12 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Skeleton.module.css' + +export type SkeletonProps = Omit, '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