Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions apps/web/src/design-audit/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,6 @@
align-items: center;
}

.design-audit-busy-mark {
width: 0.5rem;
height: 0.5rem;
margin-inline-end: 0.375rem;
border-radius: 9999px;
background: currentColor;
}

.design-audit [data-audit-state="hover"].btn-primary {
background: var(--color-brand-hover);
}
Expand Down Expand Up @@ -94,6 +86,11 @@
text-underline-offset: 0.18em;
}

.design-audit-link:is([data-audit-state="focus"], :focus-visible) {
margin-inline: -0.25rem;
padding-inline: 0.25rem;
}

.design-audit-link[data-audit-state="hover"] {
color: var(--color-brand-hover);
text-decoration-color: currentColor;
Expand All @@ -114,14 +111,19 @@
gap: 1rem;
}

.design-audit-field-grid label {
.design-audit-field-grid label,
.design-audit-field-control {
display: grid;
gap: 0.375rem;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
}

.design-audit-field-control .cv-select-trigger {
width: 100%;
}

.design-audit-field-grid :is(input, select, textarea) {
width: 100%;
min-width: 0;
Expand Down
27 changes: 20 additions & 7 deletions apps/web/src/design-audit/controls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CheckIcon, ChevronIcon, LoaderIcon, PlusIcon, WarningIcon } from "@chopin/icons";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@chopin/visuals";

import { AuditPlate, StateLabel } from "./frame";

Expand Down Expand Up @@ -70,7 +71,7 @@ export function Controls() {
</StateSample>
<StateSample state="Busy">
<button aria-busy="true" className="btn btn-md btn-primary" disabled type="button">
<span aria-hidden="true" className="design-audit-busy-mark" />Saving
<LoaderIcon aria-hidden="true" data-button-loader="" />Saving
</button>
</StateSample>
</div>
Expand Down Expand Up @@ -157,12 +158,24 @@ export function Controls() {
readOnly
/>
</label>
<label htmlFor="audit-field-select">
Select<select className="field" defaultValue="active" id="audit-field-select">
<option value="active">Active documents</option>
<option value="archived">Archived documents</option>
</select>
</label>
<div className="design-audit-field-control">
<span id="audit-field-select-label">Select</span>
<Select
defaultValue="active"
items={[
{ value: "active", label: "Active documents" },
{ value: "archived", label: "Archived documents" },
]}
>
<SelectTrigger aria-labelledby="audit-field-select-label" id="audit-field-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active documents</SelectItem>
<SelectItem value="archived">Archived documents</SelectItem>
</SelectContent>
</Select>
</div>
<label className="design-audit-field-wide" htmlFor="audit-field-textarea">
Textarea<textarea
className="field"
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/design-audit/foundations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function Foundations() {
<AuditPlate
item="spacing"
title="Spacing"
description="Common intervals on the four-pixel base scale."
description="Common intervals on the four-pixel base scale, plus the semantic button edge."
>
<div className="design-audit-spacing-scale">
{SPACING.map(value => (
Expand All @@ -176,6 +176,10 @@ export function Foundations() {
<span style={{ width: value }} />
</div>
))}
<div>
<code>--button-edge-width</code>
<span style={{ width: "var(--button-edge-width)" }} />
</div>
</div>
</AuditPlate>
<AuditPlate
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/design-audit/specimens.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { createElement } from "react";
import { SelectItem } from "@chopin/visuals";
import { Children, createElement, isValidElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { Controls } from "./controls";
Expand All @@ -8,12 +9,27 @@ import { Foundations } from "./foundations";
import { AUDIT_INVENTORY } from "./inventory";
import { Surfaces } from "./surfaces";

import type { ReactElement, ReactNode } from "react";

function plate(markup: string, item: string): string {
let start = markup.indexOf(`<section class="design-audit-plate" data-audit-item="${item}">`);
let end = markup.indexOf("</section>", start);
return start === -1 || end === -1 ? "" : markup.slice(start, end + "</section>".length);
}

function elementsOfType(
node: ReactNode,
type: ReactElement["type"],
found: ReactElement<{ children?: ReactNode; value?: unknown }>[] = [],
) {
Children.forEach(node, child => {
if (!isValidElement<{ children?: ReactNode; value?: unknown }>(child)) return;
if (child.type === type) found.push(child);
elementsOfType(child.props.children, type, found);
});
return found;
}

describe("design audit specimens", () => {
it("renders every foundation family with a visible label", () => {
let markup = renderToStaticMarkup(createElement(Foundations));
Expand All @@ -23,6 +39,7 @@ describe("design audit specimens", () => {
}
expect(markup).toContain("Consolidated exact duplicate");
expect(markup).toContain("Strong resting");
expect(markup).toContain("--button-edge-width");
for (
let measurement of [
"13px / 20px line-height",
Expand Down Expand Up @@ -198,6 +215,28 @@ describe("design audit specimens", () => {
expect(markup).toContain("disabled");
expect(markup).toContain('aria-selected="true"');
expect(markup).toContain('role="menu"');
expect(markup).toContain('data-slot="select-trigger"');
expect(markup).toContain('data-slot="select-value"');
expect(markup).not.toContain('<select class="field"');
expect(markup).toMatch(/aria-busy="true"[^>]*disabled[^>]*>[\s\S]*data-button-loader/);
expect(
elementsOfType(Controls(), SelectItem).map(item => [item.props.value, item.props.children]),
).toEqual([
["active", "Active documents"],
["archived", "Archived documents"],
]);
});

it("gives focused audit links breathing room", async () => {
let css = await Bun.file(new URL("./controls.css", import.meta.url)).text();

expect(css).toMatch(
new RegExp(
String.raw`\.design-audit-link:is\(\[data-audit-state="focus"\], :focus-visible\)`
+ String.raw`[\s\S]*margin-inline:\s*-0\.25rem;`
+ String.raw`[\s\S]*padding-inline:\s*0\.25rem;`,
),
);
});

it("renders every application surface and its meaningful states", () => {
Expand All @@ -213,6 +252,10 @@ describe("design audit specimens", () => {
expect(markup).toContain("Avatar image loading");
expect(markup).toContain("Editing this question");
expect(markup).toContain('role="alert"');
expect(markup).toMatch(
/<button aria-busy="true" class="btn btn-md btn-primary" disabled=""[^>]*>/,
);
expect(markup).toContain('data-button-loader=""');
});

it("renders every authored-content family through the static editor or record card", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/design-audit/surfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ function DialogSpecimens() {
</label>
<div className="design-audit-dialog-actions">
<button className="btn btn-md btn-secondary" disabled type="button">Cancel</button>
<button className="btn btn-md btn-primary" disabled type="button">
<LoaderIcon aria-hidden="true" />Saving
<button aria-busy="true" className="btn btn-md btn-primary" disabled type="button">
<LoaderIcon aria-hidden="true" data-button-loader="" />Saving
</button>
</div>
</div>
Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-md);
font-size: var(--text-sm);
Expand All @@ -47,6 +48,20 @@
cursor: default;
opacity: 0.4;
}

&[aria-busy="true"]:disabled {
opacity: 0.65;
}

&[aria-busy="true"] [data-button-loader] {
animation: button-loader-spin 800ms linear infinite;
}
}

@keyframes button-loader-spin {
to {
transform: rotate(360deg);
}
}

@utility btn-md {
Expand All @@ -69,8 +84,8 @@
background-color: var(--color-brand);
box-shadow: var(--shadow-resting);
color: var(--color-page);
outline: var(--edge-width) solid rgb(0 0 0 / 15%);
outline-offset: calc(-1 * var(--edge-width));
outline: var(--button-edge-width) solid var(--color-control-edge);
outline-offset: calc(-1 * var(--button-edge-width));

&:hover:not(:disabled) {
background-color: var(--color-brand-hover);
Expand All @@ -84,8 +99,8 @@
@utility btn-secondary {
background-color: var(--color-gray-200);
color: var(--color-text-primary);
outline: var(--edge-width) solid var(--color-edge);
outline-offset: calc(-1 * var(--edge-width));
outline: var(--button-edge-width) solid var(--color-edge);
outline-offset: calc(-1 * var(--button-edge-width));

&:hover:not(:disabled) {
background-color: var(--color-gray-300);
Expand All @@ -112,8 +127,8 @@
@utility btn-destructive {
background-color: var(--color-destructive);
color: var(--color-page);
outline: var(--edge-width) solid var(--color-edge);
outline-offset: calc(-1 * var(--edge-width));
outline: var(--button-edge-width) solid var(--color-control-edge);
outline-offset: calc(-1 * var(--button-edge-width));

&:hover:not(:disabled) {
background-color: var(--color-destructive-hover);
Expand All @@ -128,6 +143,7 @@
border: var(--edge-width) solid var(--color-control-edge);
border-radius: var(--radius-md);
background-color: var(--color-page);
box-shadow: var(--shadow-resting);
color: var(--color-text-primary);

&:disabled {
Expand Down Expand Up @@ -180,6 +196,7 @@
}

&[type="radio"]:checked {
border-width: calc(var(--edge-width) * 2);
box-shadow: inset 0 0 0 3px var(--color-page);
}

Expand Down Expand Up @@ -626,6 +643,10 @@ body {
border-radius: 9999px;
}
@media (prefers-reduced-motion: reduce) {
.btn[aria-busy="true"] [data-button-loader] {
animation: none;
}

.motion-dropdown,
.motion-popover,
.motion-panel,
Expand Down
36 changes: 32 additions & 4 deletions apps/web/src/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,19 +283,47 @@ describe("controls", () => {
let primary = utility("btn-primary").split("&:")[0]!;
let secondary = utility("btn-secondary").split("&:")[0]!;
let destructive = utility("btn-destructive").split("&:")[0]!;
expect(primary).toContain("outline: var(--edge-width) solid rgb(0 0 0 / 15%)");
expect(secondary).toContain("outline: var(--edge-width) solid var(--color-edge)");
expect(destructive).toContain("outline: var(--edge-width) solid var(--color-edge)");
expect(declared("--button-edge-width")).toBe("1px");
expect(primary).toContain("outline: var(--button-edge-width) solid var(--color-control-edge)");
expect(secondary).toContain(
"outline: var(--button-edge-width) solid var(--color-edge)",
);
expect(destructive).toContain(
"outline: var(--button-edge-width) solid var(--color-control-edge)",
);
for (let rest of [primary, secondary, destructive]) {
expect(rest).toContain("outline-offset: calc(-1 * var(--edge-width))");
expect(rest).toContain("outline-offset: calc(-1 * var(--button-edge-width))");
}
});

it("spaces button icons and gives busy actions a readable disabled state", () => {
let button = utility("btn");
expect(button).toContain("gap: calc(var(--spacing) * 2)");
expect(button).toMatch(/&\[aria-busy="true"\]:disabled\s*\{[\s\S]*opacity:\s*0\.65/);
expect(button).toMatch(
/&\[aria-busy="true"\] \[data-button-loader\]\s*\{[\s\S]*animation:\s*button-loader-spin/,
);
expect(THEME).toMatch(
new RegExp(
String.raw`@media \(prefers-reduced-motion: reduce\)\s*\{`
+ String.raw`[\s\S]*\.btn\[aria-busy="true"\] \[data-button-loader\]\s*\{`
+ String.raw`[\s\S]*animation:\s*none`,
),
);
});

it("raises primary buttons with the resting shadow", () => {
let primary = utility("btn-primary").split("&:")[0]!;
expect(primary).toContain("box-shadow: var(--shadow-resting)");
});

it("raises fields subtly and strengthens selected radio boundaries", () => {
expect(utility("field")).toContain("box-shadow: var(--shadow-resting)");
expect(utility("choice-control")).toMatch(
/&\[type="radio"\]:checked\s*\{[\s\S]*border-width:\s*calc\(var\(--edge-width\) \* 2\)/,
);
});

it("dims each button's default style when disabled", () => {
let rule = utility("btn");
let disabled = /&:disabled\s*\{([^}]*)\}/.exec(rule)?.[1] ?? "";
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/visuals/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"./theme.css": "./theme.css"
},
"dependencies": {
"@base-ui/react": "1.7.0",
"@chopin/icons": "workspace:*"
},
"peerDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/visuals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { MiniBars } from "./ui/mini-bars";
export type { MiniBarsProps } from "./ui/mini-bars";
export { ProgressBar } from "./ui/progress-bar";
export type { ProgressBarProps } from "./ui/progress-bar";
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type { SemanticTone } from "./ui/semantic-tone";
export { Sparkline } from "./ui/sparkline";
export type { SparklineProps } from "./ui/sparkline";
Expand Down
1 change: 1 addition & 0 deletions packages/visuals/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
@import "./ui/icon-label.css";
@import "./ui/mini-bars.css";
@import "./ui/progress-bar.css";
@import "./ui/select.css";
@import "./ui/sparkline.css";
@import "./ui/table.css";
Loading
Loading