mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(design-system): add focused pages for every component
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
228
desktop-next/src/features/design-system/catalog/state-guides.ts
Normal file
228
desktop-next/src/features/design-system/catalog/state-guides.ts
Normal 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.",
|
||||
],
|
||||
};
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
207
desktop-next/src/features/design-system/ui/ComponentPage.tsx
Normal file
207
desktop-next/src/features/design-system/ui/ComponentPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
162
desktop-next/tests/component-pages.spec.ts
Normal file
162
desktop-next/tests/component-pages.spec.ts
Normal 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`),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user