feat(design-system): add focused pages for every component

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-09 11:37:23 -07:00
parent 8e18061562
commit 8e786de355
11 changed files with 870 additions and 1 deletions

View File

@@ -13,6 +13,20 @@ pnpm --filter buzz-desktop-next dev
Open `http://localhost:1430/design`. Use another port when needed:
`pnpm --filter buzz-desktop-next dev --port 5188`.
## Focused component pages
Click a component heading or **Explore states** in the catalog to open
`/design/components/<id>` (for example, `/design/components/input`). Every catalog
entry has a dedicated URL with an isolated live playground, component-specific
state guidance, and its example module. Fields, checkboxes, switches, tabs,
progress, and meters also include side-by-side state samples using the production
components. Reset example restores both the playground and its state samples.
Use the component picker or Previous / Next links to move through the catalog.
All components returns to that component's original catalog anchor. Direct links
and refreshes work with the same SPA fallback as the rest of the site; unknown
component IDs have a recovery link. Theme and density preferences are shared.
## Appearance
The navigation contains independent theme and Normal / Compact controls. Density

View File

@@ -48,3 +48,11 @@ Density coverage checks pointer/keyboard switching, root and portal geometry,
unchanged text size, retained drafts, reload and cross-tab persistence, invalid
stored values, save failure and retry, live foundation values, compositions,
Glass tab selection, and all 55 examples at 390/884/1440 in both themes.
Focused-page coverage visits all 55 catalog deep links and checks that only the
selected playground renders. It exercises catalog links, previous/next navigation,
the component picker, anchor return, unknown-ID recovery, reset of live and state
examples, invalid/read-only/disabled fields, indeterminate checkbox interaction,
disabled and selected tabs, portal density, refresh, and appearance preferences.
Representative pages are checked at 390/884/1440 in both densities and audited in
both themes, with distinct per-component screenshots.

View File

@@ -0,0 +1,24 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { CATALOG } from "@/features/design-system/catalog/registry";
import {
ComponentNotFound,
ComponentPage,
} from "@/features/design-system/ui/ComponentPage";
// The underscore keeps the URL nested without rendering the entire catalog above it.
export const Route = createFileRoute("/design/components_/$componentId")({
beforeLoad: ({ params }) => {
const entry = CATALOG.find(
(candidate) => candidate.id === params.componentId,
);
if (!entry) throw notFound();
return { entry };
},
component: FocusedComponent,
notFoundComponent: ComponentNotFound,
});
function FocusedComponent() {
const { entry } = Route.useRouteContext();
return <ComponentPage key={entry.id} entry={entry} />;
}

View File

@@ -43,6 +43,34 @@
outline: 2px solid var(--ring-focus);
outline-offset: 4px;
}
.component-workspace {
display: grid;
gap: var(--space-page-inset);
grid-template-columns: minmax(0, 1fr);
}
.component-playground {
display: grid;
align-items: center;
min-height: var(--size-catalog-preview);
padding-block: var(--space-panel-inset);
}
.component-playground > * {
min-width: 0;
max-width: 100%;
}
.component-state-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--space-panel-inset);
}
.component-page > header h1:focus {
outline: none;
}
@media (min-width: 1280px) {
.component-workspace {
grid-template-columns: minmax(0, 1fr) 16rem;
}
}
@media (max-width: 760px) {
.catalog-grid {
grid-template-columns: minmax(0, 1fr);

View File

@@ -0,0 +1,228 @@
/** States reachable in the live examples, rather than simulated screenshots. */
export const STATE_GUIDES: Record<string, readonly string[]> = {
"ai-composer": [
"Start empty, then write a multiline draft with Shift+Enter.",
"Add or remove context and attachments; choose a model and action policy.",
"Send to see generation and Stop. Simulate a send failure to inspect recovery.",
],
buttons: [
"Compare all six emphasis variants and the three sizes.",
"Inspect disabled and loading actions alongside enabled buttons.",
"Hover, press, or Tab to a button to compare pointer and keyboard feedback.",
],
input: [
"Compare empty, filled, invalid, read-only, and disabled fields below.",
"Tab into the editable field and type to inspect its focus and content states.",
],
textarea: [
"Compare empty, filled, invalid, read-only, and disabled writing areas.",
"Write multiple lines and resize the editable example.",
],
"input-group": [
"Type a query and inspect how the leading icon and shortcut stay aligned.",
"Tab into the input to see the shared focus treatment.",
],
"form-field": [
"Submit empty or malformed email to inspect validation.",
"Enter a valid email and submit to see confirmation.",
],
checkbox: [
"Toggle the live choice with a click or Space.",
"Compare unchecked, checked, indeterminate, and disabled states below.",
],
"checkbox-group-fieldset": [
"Select several channels, then deselect them independently.",
"Tab between choices and use Space to change selection.",
],
"radio-group": [
"Select either visibility option.",
"Use arrow keys to move the single selection within the group.",
],
switch: [
"Switch the live setting on and off with a click or Space.",
"Compare on, off, and disabled examples below.",
],
slider: [
"Drag the thumb or use arrow keys to adjust the value.",
"Use Home and End to inspect the minimum and maximum.",
],
"number-field": [
"Type a number, or use the increment and decrement actions.",
"Try the bounds of 1 and 12 to inspect unavailable step actions.",
],
"input-otp": [
"Enter a six-digit code; inspect the active digit as focus advances.",
"Paste a code, or use Backspace to edit earlier digits.",
],
combobox: [
"Open the choices, then type to filter them.",
"Search for a nonexistent project to see the empty state.",
"Use arrow keys and Enter to select; Escape dismisses the popup.",
],
autocomplete: [
"Type a known project to inspect suggestions.",
"Enter a new project name to retain free text.",
"Use arrow keys to explore suggestions and Escape to dismiss.",
],
command: [
"Type to filter actions or show an empty result.",
"Choose with Enter or a click; filtering alone does not execute an action.",
],
calendar: [
"Select or clear a date and navigate between months.",
"Compare today and the selected day, including keyboard focus.",
],
dialog: [
"Open the dialog to inspect its title, description, and action.",
"Tab through the modal, then dismiss with Done or Escape to restore trigger focus.",
],
"alert-dialog": [
"Open the decision and cancel with Keep draft.",
"Confirm deletion to see the local result, then Restore.",
],
sheet: [
"Open the side surface and inspect its content at different widths.",
"Dismiss with Close details or Escape to return focus.",
],
drawer: [
"Open the bottom surface and inspect its anchored position.",
"Use Continue building or Escape to dismiss.",
],
popover: [
"Open the anchored share options.",
"Dismiss with Got it, Escape, or a click outside.",
],
tooltip: [
"Hover the trigger or focus it with the keyboard.",
"Move away or press Escape to dismiss the help.",
],
"hover-card": [
"Hover the team link to inspect its preview.",
"Move away to close; essential link text stays on the page.",
],
"dropdown-menu": [
"Open the menu and explore the highlighted item with arrow keys.",
"Choose an action to see the local result, or Escape to dismiss.",
],
"context-menu": [
"Right-click the target, or focus it and press Shift+F10.",
"Choose Copy link or Mark as unread to inspect the result.",
],
select: [
"Open the choices and inspect the selected indicator.",
"Use arrow keys and Enter to choose another digest frequency.",
],
toast: [
"Show a notification to inspect its entrance and status text.",
"Show several to inspect stacking; dismiss with the close action.",
],
accordion: [
"Expand and collapse each heading.",
"Use Tab and Enter or Space to compare keyboard interaction.",
],
collapsible: [
"Reveal the implementation notes and hide them again.",
"Focus the trigger to inspect its expanded and collapsed semantics.",
],
tabs: [
"Switch between Overview, Activity, and Files to inspect the sliding indicator.",
"Use arrow keys to focus a tab, then Enter to select it.",
"Compare the solid and glass tracks and the disabled tab below.",
],
toggle: [
"Press to pin, then press again to unpin.",
"Use Space to compare the same states with the keyboard.",
],
"toggle-group": [
"Switch the timeline scale between day, week, and month.",
"Inspect selected and unselected choices with keyboard navigation.",
],
toolbar: [
"Use arrow keys to move among the grouped actions.",
"Activate an action to inspect its local status result.",
],
menubar: [
"Open Project or View, then move among their menu items.",
"Choose an action or dismiss with Escape.",
],
"navigation-menu": [
"Open Foundations to reveal its nested destinations.",
"Follow a link to inspect navigation into a foundation page.",
],
breadcrumb: [
"Compare linked ancestors and the current location.",
"Tab to an ancestor and follow it back to the catalog.",
],
sidebar: [
"Inspect the current destination alongside the other links.",
"Tab through the destinations and follow a link.",
],
pagination: [
"Move through pages 1 to 3.",
"Compare the current page and the disabled Previous or Next action at each bound.",
],
card: [
"Inspect the label, grouped heading, body, and action as one composition.",
"Switch density to compare panel insets and heading spacing.",
],
badge: [
"Compare neutral, accent, success, warning, danger, and information tones.",
"Compare the text-only badges with the icon and text success badge.",
],
alert: [
"Compare information, success, warning, and failure messages.",
"Switch theme to inspect text and fill pairings.",
],
avatar: [
"Compare initials and the agent icon fallback.",
"Switch theme and density to inspect identity at the shared control size.",
],
progress: [
"Compare empty, in-progress, and complete values below.",
"Inspect both the visual fill and the accessible value.",
],
meter: [
"Compare low, medium, and full measurements below.",
"Inspect the value label alongside the fill.",
],
skeleton: [
"Inspect the loading region and its differently sized placeholders.",
"Enable reduced motion in your system to remove the pulse.",
],
spinner: [
"Inspect the named loading indicator.",
"Enable reduced motion in your system to remove rotation.",
],
"empty-state": [
"Inspect the purpose, explanation, and next action together.",
"Follow the action to see a populated composition.",
],
separator: [
"Inspect the boundary between two related text regions.",
"Switch theme to compare the quiet divider treatment.",
],
table: [
"Inspect column headings, left alignment, and status cells.",
"At a narrow width, scroll within the table rather than the whole page.",
],
chart: [
"Compare category labels, values, and proportional bars.",
"Switch density to inspect the spacing between data rows.",
],
"aspect-ratio": [
"Resize the viewport to inspect the preserved 16:9 frame.",
"Content keeps its proportions in either density.",
],
carousel: [
"Move through the three slides using Next and Previous.",
"Inspect the announced position and disabled actions at the ends.",
],
"scroll-area": [
"Scroll through all 20 activities.",
"Inspect the thin thumb as its position changes; keyboard scrolling works in the viewport.",
],
resizable: [
"Drag the separator to resize the two regions.",
"Focus the separator and use arrow keys to resize; the visible grip remains separate from its hit area.",
],
};

View File

@@ -0,0 +1,39 @@
import { Field, Input, Textarea } from "@buzz/ui";
/** Field states use the production field and input semantics. */
export function FieldStates({
kind = "input",
}: {
kind?: "input" | "textarea";
}) {
const Control = kind === "textarea" ? Textarea : Input;
return (
<div className="component-state-grid">
{(["Empty", "Filled", "Invalid", "Read-only", "Disabled"] as const).map(
(state) => (
<Field.Root
key={state}
invalid={state === "Invalid"}
disabled={state === "Disabled"}
>
<Field.Label>{state}</Field.Label>
<Control
placeholder="A place to build"
defaultValue={state === "Empty" ? "" : "Build something together"}
readOnly={state === "Read-only"}
disabled={state === "Disabled"}
aria-invalid={state === "Invalid" || undefined}
/>
{state === "Invalid" && (
<Field.Error match>Choose a different project name.</Field.Error>
)}
</Field.Root>
),
)}
</div>
);
}
export function TextareaStates() {
return <FieldStates kind="textarea" />;
}

View File

@@ -0,0 +1,47 @@
import { Meter, Progress } from "@buzz/ui";
/** Empty, partial, and complete values rendered by the actual progress API. */
export function ProgressStates() {
return (
<div className="component-state-grid">
{[0, 50, 100].map((value) => (
<Progress.Root key={value} value={value}>
<div className="bui-inline">
<Progress.Label>
{value === 0
? "Not started"
: value === 100
? "Complete"
: "In progress"}
</Progress.Label>
<Progress.Value />
</div>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
))}
</div>
);
}
/** A meter measures a bounded quantity independently of task completion. */
export function MeterStates() {
return (
<div className="component-state-grid">
{[10, 50, 100].map((value) => (
<Meter.Root key={value} value={value}>
<div className="bui-inline">
<Meter.Label>
{value === 10 ? "Low usage" : value === 50 ? "Half used" : "Full"}
</Meter.Label>
<Meter.Value />
</div>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
))}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useId, useState } from "react";
import { Checkbox, Switch, Tabs } from "@buzz/ui";
import { Check, Minus } from "lucide-react";
function CheckboxSample({ label }: { label: string }) {
const id = useId();
const [checked, setChecked] = useState<boolean | "mixed">(
label === "Indeterminate" ? "mixed" : label === "Checked",
);
return (
<label htmlFor={id} className="bui-inline text-body">
<Checkbox.Root
id={id}
checked={checked === true}
indeterminate={checked === "mixed"}
onCheckedChange={setChecked}
disabled={label === "Disabled"}
>
<Checkbox.Indicator>
{checked === "mixed" ? (
<Minus aria-hidden="true" />
) : (
<Check aria-hidden="true" />
)}
</Checkbox.Indicator>
</Checkbox.Root>
{label}
</label>
);
}
/** Independent, interactive check states with native disabled semantics. */
export function CheckboxStates() {
return (
<div className="component-state-grid">
{["Unchecked", "Checked", "Indeterminate", "Disabled"].map((label) => (
<CheckboxSample key={label} label={label} />
))}
</div>
);
}
/** Switch samples retain the same keyboard and pointer behavior as the catalog. */
export function SwitchStates() {
const id = useId();
return (
<div className="component-state-grid">
{["Off", "On", "Disabled off", "Disabled on"].map((state) => (
<label
key={state}
htmlFor={`${id}-${state.replaceAll(" ", "-")}`}
className="bui-inline text-body"
>
<Switch.Root
id={`${id}-${state.replaceAll(" ", "-")}`}
defaultChecked={state === "On" || state === "Disabled on"}
disabled={state.startsWith("Disabled")}
>
<Switch.Thumb />
</Switch.Root>
{state}
</label>
))}
</div>
);
}
/** Both supported tab materials and a disabled destination. */
export function TabsStates() {
return (
<div className="component-state-grid">
{(["solid", "glass"] as const).map((variant) => (
<section
key={variant}
className={
variant === "glass" ? "bg-app rounded-container p-4" : "p-4"
}
>
<h3 className="text-label mb-4">
{variant === "glass" ? "Glass" : "Solid"}
</h3>
<Tabs.Root defaultValue="overview">
<Tabs.List variant={variant} aria-label={`${variant} example`}>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab value="files" disabled>
Files
</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Panel value="overview">People building together.</Tabs.Panel>
<Tabs.Panel value="activity">Three tasks completed.</Tabs.Panel>
</Tabs.Root>
</section>
))}
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Button, Select } from "@buzz/ui";
import { Check, ChevronDown, RotateCcw } from "lucide-react";
import { CATALOG, type CatalogEntry } from "../catalog/registry";
import { STATE_GUIDES } from "../catalog/state-guides";
import { FieldStates, TextareaStates } from "../catalog/states/fields";
import {
CheckboxStates,
SwitchStates,
TabsStates,
} from "../catalog/states/selection";
import { MeterStates, ProgressStates } from "../catalog/states/measurements";
import { PageHeader } from "./primitives";
const STATE_EXAMPLES = {
input: FieldStates,
textarea: TextareaStates,
checkbox: CheckboxStates,
switch: SwitchStates,
tabs: TabsStates,
progress: ProgressStates,
meter: MeterStates,
};
const choices = CATALOG.map((entry) => ({
value: entry.id,
label: entry.name,
}));
function ComponentPicker({ entry }: { entry: CatalogEntry }) {
const navigate = useNavigate();
return (
<Select.Root
items={choices}
value={entry.id}
onValueChange={(value) => {
if (value)
void navigate({
to: "/design/components/$componentId",
params: { componentId: value },
});
}}
>
<Select.Trigger aria-label="Jump to component">
<Select.Value />
<Select.Icon>
<ChevronDown aria-hidden="true" className="bui-icon" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={8}>
<Select.Popup>
<Select.List>
{choices.map((choice) => (
<Select.Item key={choice.value} value={choice.value}>
<Select.ItemText>{choice.label}</Select.ItemText>
<Select.ItemIndicator>
<Check aria-hidden="true" className="bui-icon" />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}
/** One component's live behavior, visible states, and navigation through the catalog. */
export function ComponentPage({ entry }: { entry: CatalogEntry }) {
const [revision, setRevision] = useState(0);
const heading = useRef<HTMLHeadingElement>(null);
const index = CATALOG.findIndex((candidate) => candidate.id === entry.id);
const previous = CATALOG[index - 1];
const next = CATALOG[index + 1];
const StateExamples = STATE_EXAMPLES[entry.id as keyof typeof STATE_EXAMPLES];
useEffect(() => {
const originalTitle = document.title;
document.title = `${entry.name} — Buzz Design System`;
heading.current?.focus({ preventScroll: true });
return () => {
document.title = originalTitle;
};
}, [entry.name]);
return (
<article className="component-page" aria-labelledby="component-page-title">
<div className="bui-inline justify-between mb-8">
<Link
to="/design/components"
hash={entry.id}
className="text-label text-accent"
>
← All components
</Link>
<ComponentPicker entry={entry} />
</div>
<header className="mb-10 bui-stack">
<p className="text-meta text-tertiary">{entry.category}</p>
<h1
ref={heading}
tabIndex={-1}
id="component-page-title"
className="text-title text-primary"
>
{entry.name}
</h1>
<p className="text-body-lg text-secondary max-w-2xl">
{entry.description}
</p>
</header>
<div className="component-workspace">
<section aria-labelledby="playground-title" className="min-w-0">
<div className="bui-inline justify-between mb-4">
<h2 id="playground-title" className="text-heading">
Playground
</h2>
<Button
size="sm"
variant="ghost"
onClick={() => setRevision((value) => value + 1)}
>
<RotateCcw aria-hidden="true" /> Reset example
</Button>
</div>
<div className="component-playground" key={`${entry.id}-${revision}`}>
<entry.Preview />
</div>
</section>
<aside aria-labelledby="states-title" className="min-w-0">
<h2 id="states-title" className="text-subheading mb-4">
States to explore
</h2>
<ul className="bui-stack text-body text-secondary list-disc pl-5">
{STATE_GUIDES[entry.id].map((state) => (
<li key={state}>{state}</li>
))}
</ul>
<p className="text-caption text-tertiary mt-6">
Use the theme and density controls to inspect the same component in
each setting.
</p>
</aside>
</div>
{StateExamples && (
<section aria-labelledby="state-examples-title" className="my-10">
<h2 id="state-examples-title" className="text-heading mb-6">
State examples
</h2>
<div key={`${entry.id}-states-${revision}`}>
<StateExamples />
</div>
</section>
)}
<details className="catalog-source mt-10">
<summary className="text-caption text-secondary">
View example module
</summary>
<pre className="text-code">
<code>{entry.source}</code>
</pre>
</details>
<nav
aria-label="Browse components"
className="bui-inline justify-between mt-10"
>
{previous ? (
<Link
className="text-label text-accent"
to="/design/components/$componentId"
params={{ componentId: previous.id }}
>
← Previous: {previous.name}
</Link>
) : (
<span />
)}
{next && (
<Link
className="text-label text-accent"
to="/design/components/$componentId"
params={{ componentId: next.id }}
>
Next: {next.name} →
</Link>
)}
</nav>
</article>
);
}
/** Keep an unknown deep link recoverable within the design-system navigation. */
export function ComponentNotFound() {
return (
<>
<PageHeader
title="Component not found"
intro="This component page does not exist. Explore the catalog to find an example."
/>
<Link to="/design/components" className="text-label text-accent">
Browse all components
</Link>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Button, Field, Input } from "@buzz/ui";
import { CATALOG } from "../catalog/registry";
import { PageHeader } from "./primitives";
@@ -67,7 +68,12 @@ export function ComponentsPage() {
<div>
<p className="text-meta text-tertiary">{entry.category}</p>
<h2 id={`${entry.id}-title`} className="text-heading mt-1">
<a href={`#${entry.id}`}>{entry.name}</a>
<Link
to="/design/components/$componentId"
params={{ componentId: entry.id }}
>
{entry.name}
</Link>
</h2>
<p className="text-caption text-secondary mt-2">
{entry.description}
@@ -76,6 +82,14 @@ export function ComponentsPage() {
<div className="catalog-preview">
<entry.Preview />
</div>
<Link
to="/design/components/$componentId"
params={{ componentId: entry.id }}
className="text-label text-accent"
aria-label={`Explore ${entry.name}`}
>
Explore states →
</Link>
<details className="catalog-source">
<summary className="text-caption text-secondary">
View example source

View File

@@ -0,0 +1,162 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../../desktop/tests/helpers/animations";
test("every catalog entry links to its own focused component page", async ({
page,
}) => {
await page.goto("/design/components");
const entries = await page
.locator(".catalog-entry h2 a")
.evaluateAll((links) =>
links.map((link) => ({
name: link.textContent ?? "",
href: link.getAttribute("href") ?? "",
})),
);
expect(entries).toHaveLength(55);
expect(new Set(entries.map((entry) => entry.href)).size).toBe(55);
for (const entry of entries) {
await page.goto(entry.href);
await expect(
page.getByRole("heading", { level: 1, name: entry.name, exact: true }),
).toBeVisible();
await expect(page.locator(".component-playground")).toHaveCount(1);
await expect(page.locator(".catalog-entry")).toHaveCount(0);
expect(
await page
.getByRole("complementary", { name: "States to explore" })
.getByRole("listitem")
.count(),
).toBeGreaterThan(0);
await expect(page).toHaveTitle(`${entry.name} — Buzz Design System`);
}
});
test("catalog navigation, picker, previous/next, reset, and invalid links work", async ({
page,
}) => {
await page.goto("/design/components");
await page.getByRole("button", { name: "Compact", exact: true }).click();
await page.getByRole("link", { name: "Explore Input", exact: true }).click();
await expect(page).toHaveURL(/\/design\/components\/input$/);
await expect(page.getByRole("heading", { level: 1 })).toBeFocused();
const input = page.getByRole("textbox", {
name: "Project name",
exact: true,
});
await input.fill("A temporary draft");
await expect(input).toHaveCSS("height", "32px");
await page.getByRole("button", { name: "Reset example" }).click();
await expect(input).toHaveValue("");
await page
.getByRole("link", { name: "Next: Textarea", exact: false })
.click();
await expect(page).toHaveURL(/\/textarea$/);
await page
.getByRole("link", { name: "Previous: Input", exact: false })
.click();
await expect(page).toHaveURL(/\/input$/);
await page.getByRole("combobox", { name: "Jump to component" }).click();
await page.getByRole("option", { name: "Dialog", exact: true }).click();
await expect(page).toHaveURL(/\/dialog$/);
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCSS("padding", "16px");
await page.keyboard.press("Escape");
await page.reload();
await expect(
page.getByRole("heading", { level: 1, name: "Dialog" }),
).toBeVisible();
await page
.getByRole("link", { name: "All components", exact: false })
.filter({ hasText: "←" })
.click();
await expect(page).toHaveURL(/\/design\/components#dialog$/);
await expect(page.locator("#dialog")).toBeInViewport();
await page.goto("/design/components/not-a-component");
await expect(
page.getByRole("heading", { name: "Component not found" }),
).toBeVisible();
await page.getByRole("link", { name: "Browse all components" }).click();
await expect(page.locator(".catalog-entry")).toHaveCount(55);
});
test("state samples use real validation, disabled, selection, and reset behavior", async ({
page,
}) => {
await page.goto("/design/components/input");
const invalid = page.getByRole("textbox", { name: "Invalid", exact: true });
await expect(invalid).toHaveAttribute("aria-invalid", "true");
await expect(
page.getByText("Choose a different project name."),
).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Disabled", exact: true }),
).toBeDisabled();
await expect(
page.getByRole("textbox", { name: "Read-only", exact: true }),
).not.toBeEditable();
await page.goto("/design/components/checkbox");
const mixed = page.getByRole("checkbox", {
name: "Indeterminate",
exact: true,
});
await expect(mixed).toHaveAttribute("aria-checked", "mixed");
await mixed.focus();
await mixed.press("Space");
await expect(mixed).toBeChecked();
await expect(mixed).not.toHaveAttribute("aria-checked", "mixed");
await page.getByRole("button", { name: "Reset example" }).click();
await expect(mixed).toHaveAttribute("aria-checked", "mixed");
await page.goto("/design/components/tabs");
const tabs = page.getByRole("tablist", { name: "glass example" });
await expect(tabs.getByRole("tab", { name: "Files" })).toHaveAttribute(
"aria-disabled",
"true",
);
await tabs.getByRole("tab", { name: "Activity" }).click();
await expect(tabs.getByRole("tab", { name: "Activity" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.getByRole("button", { name: "Reset example" }).click();
await expect(tabs.getByRole("tab", { name: "Overview" })).toHaveAttribute(
"aria-selected",
"true",
);
});
test("focused pages remain readable and accessible in both themes and densities", async ({
page,
}, testInfo) => {
for (const component of ["input", "ai-composer", "tabs"]) {
await page.goto(`/design/components/${component}`);
for (const width of [390, 884, 1440]) {
await page.setViewportSize({ width, height: 960 });
for (const density of ["Normal", "Compact"]) {
await page.getByRole("button", { name: density, exact: true }).click();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
}
}
for (const theme of ["light", "dark"]) {
const toggle = page.getByRole("button", {
name: `Switch to ${theme} mode`,
});
if (await toggle.count()) await toggle.click();
const result = await new AxeBuilder({ page })
.disableRules(["color-contrast"])
.analyze();
expect(result.violations).toEqual([]);
await page.getByRole("heading", { level: 1 }).scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page.screenshot({
path: testInfo.outputPath(`${component}-${theme}.png`),
});
}
}
});