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
14 changed files with 729 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
}
+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>
)
}
+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} />
}
+4
View File
@@ -2,9 +2,13 @@ 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'