Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3e7d0e45 | ||
|
|
0fa9cfe611 | ||
|
|
1dd97b123c |
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%')
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user