basis-design 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/capture.html +12 -0
- package/package.json +72 -68
- package/server/agent.ts +33 -12
- package/server/brief.ts +32 -3
- package/server/capture.ts +128 -0
- package/server/claude.ts +72 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +134 -3
- package/server/project.ts +9 -1
- package/server/setup.ts +201 -219
- package/server/template.ts +168 -0
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/server/workspace.ts +19 -1
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +45 -2
- package/src/Workbench.tsx +4 -1
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/chat/ChatPanel.tsx +10 -1
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +58 -21
- package/src/shell/Shell.module.css +1146 -1067
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -0
- package/template/components/Field.module.css +10 -0
- package/template/components/Field.tsx +6 -0
- package/template/components/specimen.ts +24 -1
- package/template/history.json +123 -0
- package/template/patterns/Navbar.module.css +38 -12
- package/template/patterns/Navbar.specimen.tsx +1 -1
- package/template/patterns/Navbar.tsx +27 -3
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ClientMessage, VerifyKitResult } from "../../shared/protocol.ts";
|
|
3
|
+
import { BrowseButton } from "./FolderField.tsx";
|
|
4
|
+
import { readPref, writePref } from "./prefs.ts";
|
|
5
|
+
import styles from "./Shell.module.css";
|
|
6
|
+
|
|
7
|
+
export interface VerifyKitProps {
|
|
8
|
+
workspace: string;
|
|
9
|
+
/** The product repo the project designs for: where the kit belongs, unless the user chose a folder before. */
|
|
10
|
+
repo: string | undefined;
|
|
11
|
+
progress: { step: string; done?: number; total?: number } | null;
|
|
12
|
+
result: VerifyKitResult | null;
|
|
13
|
+
send: (message: ClientMessage) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The product folder the kit was last written to, per project. */
|
|
17
|
+
const targetPref = (workspace: string) => `verify-target:${workspace}`;
|
|
18
|
+
|
|
19
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
20
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The verification kit tab of the Export dialog: builds the kit (a runnable copy of the design, screenshots of
|
|
25
|
+
* every view's screens and the scripts that check an implementation against them) as a zip or into the product's
|
|
26
|
+
* folder.
|
|
27
|
+
*/
|
|
28
|
+
export function VerifyKit({ workspace, repo, progress, result: latest, send }: VerifyKitProps) {
|
|
29
|
+
const [target, setTarget] = useState(() => readPref(targetPref(workspace)) ?? repo ?? "");
|
|
30
|
+
const [browseError, setBrowseError] = useState<string | null>(null);
|
|
31
|
+
// Only show results of kits built while this dialog is open.
|
|
32
|
+
const [stale] = useState(latest);
|
|
33
|
+
const result = latest === stale ? null : latest;
|
|
34
|
+
const busy = progress !== null;
|
|
35
|
+
|
|
36
|
+
const build = (project?: string) => {
|
|
37
|
+
if (project !== undefined) writePref(targetPref(workspace), project);
|
|
38
|
+
send({ type: "verify-kit", project });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className={styles.kit}>
|
|
43
|
+
<p className={styles.kitIntro}>
|
|
44
|
+
Proof that a feature was built as designed. The kit holds a runnable copy of the design, screenshots of every
|
|
45
|
+
view's screens at the widths the view asks for, and two scripts: one captures the implementation the same way,
|
|
46
|
+
the other compares the images pixel for pixel. Give it to the agent that builds the feature; the target is 0%
|
|
47
|
+
difference, and anything allowed to differ is a named exception.
|
|
48
|
+
</p>
|
|
49
|
+
<form
|
|
50
|
+
className={styles.kitWrite}
|
|
51
|
+
onSubmit={(event) => {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
build(target);
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<label htmlFor="verify-target">Write into the product's folder</label>
|
|
57
|
+
<div className={styles.kitWriteRow}>
|
|
58
|
+
<input
|
|
59
|
+
id="verify-target"
|
|
60
|
+
value={target}
|
|
61
|
+
onChange={(event) => setTarget(event.target.value)}
|
|
62
|
+
placeholder="Browse, or paste the folder's path"
|
|
63
|
+
spellCheck={false}
|
|
64
|
+
/>
|
|
65
|
+
<BrowseButton
|
|
66
|
+
title="Choose the product's folder"
|
|
67
|
+
start={target}
|
|
68
|
+
onPick={setTarget}
|
|
69
|
+
onError={setBrowseError}
|
|
70
|
+
/>
|
|
71
|
+
<button type="submit" className={styles.chromeButton} disabled={!target.trim() || busy}>
|
|
72
|
+
Write kit
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
<span className={styles.kitNote}>
|
|
76
|
+
The kit goes into <code>basis-verify/</code> there. Writing it again replaces what Basis generated and keeps
|
|
77
|
+
the project's targets, exceptions and captures.
|
|
78
|
+
</span>
|
|
79
|
+
{browseError && <p className={styles.kitError}>{browseError}</p>}
|
|
80
|
+
</form>
|
|
81
|
+
{progress && (
|
|
82
|
+
<p className={styles.kitNote} role="status">
|
|
83
|
+
{progress.step}
|
|
84
|
+
{progress.total ? ` ${progress.done ?? 0} of ${progress.total}` : ""}…
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
{result && !result.ok && <p className={styles.kitError}>{result.error}</p>}
|
|
88
|
+
{result?.ok && <Summary result={result} />}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Summary({ result }: { result: Extract<VerifyKitResult, { ok: true }> }) {
|
|
94
|
+
const { summary, dir } = result;
|
|
95
|
+
if (summary.views === 0) {
|
|
96
|
+
return (
|
|
97
|
+
<p className={styles.kitWarning}>
|
|
98
|
+
There are no views to capture. Ask the agent for a view of a screen in the product, then build the kit.
|
|
99
|
+
</p>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<p className={styles.kitSuccess}>
|
|
105
|
+
Captured {plural(summary.shots, "shot")} of {plural(summary.screens, "screen")} in{" "}
|
|
106
|
+
{plural(summary.views, "view")} with {summary.browser}.{" "}
|
|
107
|
+
{dir ? (
|
|
108
|
+
<>
|
|
109
|
+
Wrote the kit to <code>{dir}</code>.
|
|
110
|
+
</>
|
|
111
|
+
) : (
|
|
112
|
+
<a href="/export/verification-kit.zip" download>
|
|
113
|
+
Download the .zip
|
|
114
|
+
</a>
|
|
115
|
+
)}
|
|
116
|
+
</p>
|
|
117
|
+
{summary.failed.length > 0 && (
|
|
118
|
+
<p className={styles.kitWarning}>
|
|
119
|
+
{plural(summary.failed.length, "shot")} of screens that failed to render: {summary.failed.join(", ")}.
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
{summary.unstable.length > 0 && (
|
|
123
|
+
<p className={styles.kitWarning}>
|
|
124
|
+
{plural(summary.unstable.length, "shot")} came out differently on a second capture, so nothing can match them
|
|
125
|
+
exactly: {summary.unstable.join(", ")}. Look for clocks, random values or animations in those screens.
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
{summary.problems.length > 0 && (
|
|
129
|
+
<p className={styles.kitWarning}>Specimens that could not be loaded: {summary.problems.join("; ")}.</p>
|
|
130
|
+
)}
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Starter template changes
|
|
2
|
+
|
|
3
|
+
What changed in Basis' starter components, patterns and views, by template version. Basis updates the files a
|
|
4
|
+
workspace never changed by itself; the agent brings these changes into the ones it did change, keeping the
|
|
5
|
+
design system's own look.
|
|
6
|
+
|
|
7
|
+
## 1
|
|
8
|
+
|
|
9
|
+
- Reduced motion in every component and pattern: under `@media (prefers-reduced-motion: reduce)` nothing slides,
|
|
10
|
+
zooms, shimmers or loops (presses do not shrink, cards do not lift, the indeterminate progress bar and the
|
|
11
|
+
skeleton stand still, tooltips, toasts and dialogs only fade, the tab indicator and underlines jump). Spinners
|
|
12
|
+
keep turning, since they are the only sign that work goes on. See the `@media (prefers-reduced-motion: reduce)`
|
|
13
|
+
block at the end of each stylesheet.
|
|
14
|
+
- Field: the label is a `<label for>` above the control, then the hint, then the error, then the control. The hint
|
|
15
|
+
and the error are tied to the control with `aria-describedby`, the control gets `aria-invalid` from the Field's
|
|
16
|
+
error (through a React context, so TextInput, Textarea and Select need no extra props), and the error has an
|
|
17
|
+
icon as well as the danger colour.
|
|
18
|
+
- Navbar responds to its own width, as a size container: from 1024px everything sits in the bar; from 800px the
|
|
19
|
+
search becomes a button that opens the field below the bar, with focus in it; under 800px the links, the search
|
|
20
|
+
and the actions move into a menu behind a menu button. `compact` still forces the narrow layout, and the menu
|
|
21
|
+
keeps its own open state unless `menuOpen` is given.
|
|
22
|
+
- AppShell is a size container named `app-shell`; SidebarNav becomes its icon rail inside a narrow one (under
|
|
23
|
+
640px). The rail's grid column is pinned so its icons stay centred.
|
|
24
|
+
- Small text that used `textSubtle` (SidebarNav section titles, StatCard hints) uses `textMuted`, since subtle is
|
|
25
|
+
held to 3:1 and small text needs 4.5:1.
|
|
@@ -23,9 +23,19 @@
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
.error {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: flex-start;
|
|
28
|
+
gap: var(--space-1);
|
|
26
29
|
color: var(--color-danger);
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
.errorIcon {
|
|
33
|
+
flex: none;
|
|
34
|
+
width: 1.15em;
|
|
35
|
+
height: 1.15em;
|
|
36
|
+
margin-top: 0.1em;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
.input {
|
|
30
40
|
width: 100%;
|
|
31
41
|
min-width: 0;
|
|
@@ -31,6 +31,12 @@ export function Field({ label, hint, error, children }: FieldProps) {
|
|
|
31
31
|
)}
|
|
32
32
|
{error && (
|
|
33
33
|
<span id={errorId} className={styles.error}>
|
|
34
|
+
{/* An icon as well as the colour, so the error does not rely on colour alone. */}
|
|
35
|
+
<svg className={styles.errorIcon} viewBox="0 0 16 16" aria-hidden>
|
|
36
|
+
<circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
37
|
+
<path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
38
|
+
<circle cx="8" cy="11.1" r="0.9" fill="currentColor" />
|
|
39
|
+
</svg>
|
|
34
40
|
{error}
|
|
35
41
|
</span>
|
|
36
42
|
)}
|
|
@@ -57,7 +57,30 @@ export type SpecimenBlock =
|
|
|
57
57
|
* Whole screens of one flow, for views: the gallery shows one at a time, full width, with a strip to jump
|
|
58
58
|
* between them. The first screen shows first; each moves on with `go(id)`.
|
|
59
59
|
*/
|
|
60
|
-
| {
|
|
60
|
+
| {
|
|
61
|
+
kind: "flow";
|
|
62
|
+
title?: string;
|
|
63
|
+
screens: { id: string; label: string; render: ComponentType<ScreenProps> }[];
|
|
64
|
+
/** How the verification kit captures each screen; Basis' defaults when left out (see Capture). */
|
|
65
|
+
capture?: Capture;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The screenshots a view's screens are checked against during development. Every screen is captured at every
|
|
70
|
+
* width (and theme), and the implementation must match them pixel for pixel, so a screen must render the same
|
|
71
|
+
* every time: no clock, no randomness, no animation that does not settle.
|
|
72
|
+
*/
|
|
73
|
+
export interface Capture {
|
|
74
|
+
/** Viewport widths in CSS pixels; Basis' default is [375, 768, 1440]. */
|
|
75
|
+
widths?: number[];
|
|
76
|
+
/**
|
|
77
|
+
* "full" (the default) captures the whole page, which must then also be as tall as the design. A number
|
|
78
|
+
* captures exactly that viewport height, for screens such as app shells that scroll inside.
|
|
79
|
+
*/
|
|
80
|
+
height?: "full" | number;
|
|
81
|
+
/** Themes to capture; Basis' default is ["light"]. */
|
|
82
|
+
themes?: ("light" | "dark")[];
|
|
83
|
+
}
|
|
61
84
|
|
|
62
85
|
export interface Specimen {
|
|
63
86
|
/** Anchor and sidebar id; defaults to the title in kebab-case. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"components/Button.module.css": [
|
|
3
|
+
"2d8464bdd6324a9283902a51948359659223a1d14a14699ec05db3c48a1e6f5e",
|
|
4
|
+
"e1089f131e089edbea8c96cfd932e999b65e5987e7aa46ffa6ec1771bcc58913"
|
|
5
|
+
],
|
|
6
|
+
"components/Button.specimen.tsx": [
|
|
7
|
+
"78be211c4ebe57be4d3e8fbda178aa1ea4a50874427f50918d5b519b30f1366d"
|
|
8
|
+
],
|
|
9
|
+
"components/Controls.module.css": [
|
|
10
|
+
"11cd2a14dff9b941386b1e3f73904021501b5d11b92e2c2a5c7db740ccdebf78",
|
|
11
|
+
"6180d0705587ce7c9d721367b3a93968be63620470ca8053842cc9b97416c17f"
|
|
12
|
+
],
|
|
13
|
+
"components/Controls.specimen.tsx": [
|
|
14
|
+
"44d057760788eafde4648ff245bc88757148aea950d784679a9013929b2fd3f6"
|
|
15
|
+
],
|
|
16
|
+
"components/Controls.tsx": [
|
|
17
|
+
"bf2dcb69e0d11f2ff9c79be769689c1d2be4425824ecc2aafdbb9d4e916cc7a2"
|
|
18
|
+
],
|
|
19
|
+
"components/Display.module.css": [
|
|
20
|
+
"5911e1d5ca62edb6baaca2b887a92f7f75e3f71c59d333bc4bffc35d5581481e"
|
|
21
|
+
],
|
|
22
|
+
"components/Display.specimen.tsx": [
|
|
23
|
+
"4a00e90b3e9e18031132c36b3e9e2f2e338abe59901f99b3418cce9122de72a2",
|
|
24
|
+
"f833c9458080cf9cb05aca5f61b1e314ed2069466f5567f3a36add6c5f33cb53"
|
|
25
|
+
],
|
|
26
|
+
"components/Display.tsx": [
|
|
27
|
+
"88ad53b91e4faccf1c85d8e60f7f8c5a98cca4b2061ac583ee3a87796ac92661",
|
|
28
|
+
"f676585a048fea80de64e2be1cd2032d63547d6891011deec9cf3540763a039f"
|
|
29
|
+
],
|
|
30
|
+
"components/Field.module.css": [
|
|
31
|
+
"8a02df76c308cbe1c731cd5cfca4c2c7d5f1ae2492c4266d11a60c24143281d9"
|
|
32
|
+
],
|
|
33
|
+
"components/Field.tsx": [
|
|
34
|
+
"170d7e50605f5bc342fe03a0f6fcb72e24e7ad4481ddd7b73402fbb62272bb2d",
|
|
35
|
+
"9c278ae0f37efe55fd73b03204c0e3f6997d6c3c7873856798efc8ce95222ef0",
|
|
36
|
+
"ebb3f7ed65f00184d71a0697871181d8f2fb8d723dc28d94171b11e0cc44ebe7"
|
|
37
|
+
],
|
|
38
|
+
"components/Overlay.module.css": [
|
|
39
|
+
"15f18fe8494bb008e85f06a8ebd2d491826e565ddb16a6a01b270e115977838b",
|
|
40
|
+
"f1bae693a614018f19d7f9cdb2f5d9b950a711016d2639ba0ea03acbe05ef797"
|
|
41
|
+
],
|
|
42
|
+
"components/Tabs.module.css": [
|
|
43
|
+
"f794ecc0a17aea3390c72851f56a005a16b9a5c532809c57b0bd597f49faf7cc"
|
|
44
|
+
],
|
|
45
|
+
"components/Tabs.specimen.tsx": [
|
|
46
|
+
"0c871b0745e9206a6301025b0fc2e49b15b6798c8fe34499a04fe212cb0ca8d3"
|
|
47
|
+
],
|
|
48
|
+
"components/Tabs.tsx": [
|
|
49
|
+
"7a3178fd1fce2697329049a23581ed40a018777b9a8d121e92b6cf0bbb20beb1"
|
|
50
|
+
],
|
|
51
|
+
"components/index.ts": [
|
|
52
|
+
"c8e48a994b9283dbfba7cf83d15f8ccb327ea4b09f1138f84153dcd206262626"
|
|
53
|
+
],
|
|
54
|
+
"components/specimen.ts": [
|
|
55
|
+
"03370daf9bba1c4329d3dac7b76199970cab9c11a6ea2d23e1a484ead80d36d6",
|
|
56
|
+
"1f826f8c8f183e311fd20cca0e5e3c138ac2775c4e156862600c24e9dad7cf7d",
|
|
57
|
+
"2f4b9f188c981a690c271b100cf4174339637d4bd8f27b3daa200ac089b6256e",
|
|
58
|
+
"e04c6fc2a3e85090b7672d229d95ca8c5a42009f6397332230d324357b993169"
|
|
59
|
+
],
|
|
60
|
+
"patterns/AppShell.module.css": [
|
|
61
|
+
"8327a6a221d3822f31e2413ea2d83978e89dc5ed093ab348efbc3fb74659a76b"
|
|
62
|
+
],
|
|
63
|
+
"patterns/AppShell.specimen.tsx": [
|
|
64
|
+
"bbf6e40b1eb59ab81faec7d298daedf964721210acd319518370d654828e61b5"
|
|
65
|
+
],
|
|
66
|
+
"patterns/DataTable.specimen.tsx": [
|
|
67
|
+
"07ddde9be4ff6b5fbe080145b59cb98719ebf17665efea5168ce653e45e52328",
|
|
68
|
+
"fef38e643e27c8515167d03b2f9578c4f490dc3c472f39db5ec8a3f8de5b0405"
|
|
69
|
+
],
|
|
70
|
+
"patterns/DataTable.tsx": [
|
|
71
|
+
"bee57f1686bbbfeb08670475c687c18f8e33b19b09b804d2e3b857c9575f4227"
|
|
72
|
+
],
|
|
73
|
+
"patterns/EmptyState.tsx": [
|
|
74
|
+
"7b9f3ad1ad9467b537154426823da185062d99f9b5481329ec87c8e21edefe19"
|
|
75
|
+
],
|
|
76
|
+
"patterns/FormSection.specimen.tsx": [
|
|
77
|
+
"947b384b3ff7b0874fd7210b2db767623ba980142e33f6daf98261b5c725b363"
|
|
78
|
+
],
|
|
79
|
+
"patterns/Navbar.module.css": [
|
|
80
|
+
"7a4c07e69235c8b0667424a3794a7ca927fb6c1a852b174fed9c445d3ce31b05",
|
|
81
|
+
"a3d8cdfd7817bee2deca487115863b8e9a904159ccf1a62a4252269f9a00974b"
|
|
82
|
+
],
|
|
83
|
+
"patterns/Navbar.specimen.tsx": [
|
|
84
|
+
"c5dca4b21abd2fb9e6a3dc6c881dd946691eb4b193030355897cf4837c1fbd33",
|
|
85
|
+
"e0db053892604c3e48fe688481f2d7659179aa37f15f7a1733c65eaf58d2037b",
|
|
86
|
+
"ff2ef3ab16212f5d49952a425d4a55b5c1a23bbf0273032fcbf309ef4cc42425"
|
|
87
|
+
],
|
|
88
|
+
"patterns/Navbar.tsx": [
|
|
89
|
+
"629e3a72fac39fbddcef20ee2761b20637ac6ecf931e7c40ec497fb5717c76b5",
|
|
90
|
+
"a519ebc213b050332897d49ca3d3f218e0f81657b45dfeabf12ba4851e27ea9d",
|
|
91
|
+
"c30d7c50eca1d5f5403bfe279f82c0fc0892c3ec7c117eba529a2c4d67011e85"
|
|
92
|
+
],
|
|
93
|
+
"patterns/PageHeader.specimen.tsx": [
|
|
94
|
+
"ed1adf47bc0873aa2888e3f344b4e6d8949cdcbe4d5932a05c014e6552872107"
|
|
95
|
+
],
|
|
96
|
+
"patterns/SidebarNav.module.css": [
|
|
97
|
+
"c5c61ad5ef76d19fdafdd62c3e7d2762c8f4e65a659d79630679cc77143c6fcf"
|
|
98
|
+
],
|
|
99
|
+
"patterns/SidebarNav.specimen.tsx": [
|
|
100
|
+
"5ebc259eaec7004cd0836c3f108e6f26e4c209f1722a5102971edd3048bffcb4"
|
|
101
|
+
],
|
|
102
|
+
"patterns/SidebarNav.tsx": [
|
|
103
|
+
"e11bb61819ea9ff36d963eec2b92f1d7cf2b4e2b14e74edc7a9ab001338a4fc8"
|
|
104
|
+
],
|
|
105
|
+
"patterns/StatCard.module.css": [
|
|
106
|
+
"30c76bf5527125a41d5237b01aba8d7f3575df6732edc1b0636a1086e4304c45"
|
|
107
|
+
],
|
|
108
|
+
"patterns/StatCard.specimen.tsx": [
|
|
109
|
+
"1841a58bce6c35b23f8715bb00c04b641381c9c7d829accb9a2900d49bf9709d"
|
|
110
|
+
],
|
|
111
|
+
"patterns/StatCard.tsx": [
|
|
112
|
+
"55a9c20673d8a8a191ac2ee70fa27a0ec9ac65f4ea1cf8af5a7be654209e31c8"
|
|
113
|
+
],
|
|
114
|
+
"patterns/icons.tsx": [
|
|
115
|
+
"5a7c631c8ad5195f82b852814c850af74c3807e09aff1dafec465ce92adc364c"
|
|
116
|
+
],
|
|
117
|
+
"patterns/index.ts": [
|
|
118
|
+
"06b2fe73440972fbd91e9c64d8a3f4a5cf37e1f2d749cc6a821ce59f9b9d8c02"
|
|
119
|
+
],
|
|
120
|
+
"patterns/specimen-data.ts": [
|
|
121
|
+
"4e355b5dc7eeaed260e6092f88cf85194a9377bce6accce5e9db6e97e22761e3"
|
|
122
|
+
]
|
|
123
|
+
}
|
|
@@ -87,7 +87,8 @@
|
|
|
87
87
|
.link:focus-visible,
|
|
88
88
|
.link[data-force="focus"],
|
|
89
89
|
.menuLink:focus-visible,
|
|
90
|
-
.menuButton:focus-visible
|
|
90
|
+
.menuButton:focus-visible,
|
|
91
|
+
.searchButton:focus-visible {
|
|
91
92
|
outline: var(--focus-ring-width) solid var(--color-focus-ring);
|
|
92
93
|
outline-offset: calc(var(--focus-ring-width) * -1);
|
|
93
94
|
}
|
|
@@ -155,7 +156,8 @@
|
|
|
155
156
|
display: inline-flex;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
.menuButton
|
|
159
|
+
.menuButton,
|
|
160
|
+
.searchButton {
|
|
159
161
|
display: none;
|
|
160
162
|
flex: none;
|
|
161
163
|
place-items: center;
|
|
@@ -170,7 +172,8 @@
|
|
|
170
172
|
transition: background-color var(--duration-fast) var(--ease-standard);
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
.menuButton:hover
|
|
175
|
+
.menuButton:hover,
|
|
176
|
+
.searchButton:hover {
|
|
174
177
|
background: var(--color-secondary);
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -187,32 +190,55 @@
|
|
|
187
190
|
gap: 2px;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
/*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
/* Three layouts by the bar's own width. From 1024px everything sits in the bar. From 800px the search becomes a
|
|
194
|
+
button that opens it below the bar. Under 800px, or with compact, the links, the search and the actions move
|
|
195
|
+
into the menu behind the menu button. */
|
|
196
|
+
@container (width < 1024px) {
|
|
197
|
+
.bar .search {
|
|
198
|
+
display: none;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.searchButton,
|
|
202
|
+
.menu {
|
|
203
|
+
display: grid;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.menu :is(.actions, .menuLinks) {
|
|
207
|
+
display: none;
|
|
208
|
+
}
|
|
194
209
|
}
|
|
195
210
|
|
|
196
|
-
.navbar[data-compact] .
|
|
211
|
+
.navbar[data-compact] .menuButton,
|
|
212
|
+
.navbar[data-compact] .menu,
|
|
213
|
+
.navbar[data-compact] .menu .menuLinks {
|
|
197
214
|
display: grid;
|
|
198
215
|
}
|
|
199
216
|
|
|
200
|
-
.navbar[data-compact] .
|
|
217
|
+
.navbar[data-compact] .menu .actions {
|
|
218
|
+
display: flex;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.navbar[data-compact] .bar :is(.links, .search, .actions, .searchButton) {
|
|
201
222
|
display: none;
|
|
202
223
|
}
|
|
203
224
|
|
|
204
|
-
@container (width <
|
|
225
|
+
@container (width < 800px) {
|
|
205
226
|
.bar {
|
|
206
227
|
gap: var(--space-3);
|
|
207
228
|
padding-inline: var(--space-3);
|
|
208
229
|
}
|
|
209
230
|
|
|
210
231
|
.menuButton,
|
|
211
|
-
.menu
|
|
232
|
+
.menu,
|
|
233
|
+
.menu .menuLinks {
|
|
212
234
|
display: grid;
|
|
213
235
|
}
|
|
214
236
|
|
|
215
|
-
.
|
|
237
|
+
.menu .actions {
|
|
238
|
+
display: flex;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.bar :is(.links, .search, .actions, .searchButton) {
|
|
216
242
|
display: none;
|
|
217
243
|
}
|
|
218
244
|
}
|
|
@@ -101,7 +101,7 @@ export default defineSpecimen({
|
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
kind: "block",
|
|
104
|
-
title: "Narrow · under
|
|
104
|
+
title: "Narrow · under 800px the links and search move behind the menu",
|
|
105
105
|
render: () => (
|
|
106
106
|
<div style={{ ...frame, maxWidth: 380 }}>
|
|
107
107
|
<Navbar
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useId, useState, type ReactNode } from "react";
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { Avatar } from "../components/index.ts";
|
|
3
3
|
import { Icon } from "./icons.tsx";
|
|
4
4
|
import styles from "./Navbar.module.css";
|
|
@@ -25,7 +25,7 @@ export interface NavbarProps {
|
|
|
25
25
|
elevated?: boolean;
|
|
26
26
|
/**
|
|
27
27
|
* Forces the narrow layout, where links, search and actions move behind a menu button. Without it the bar switches by
|
|
28
|
-
* itself
|
|
28
|
+
* itself: under 1024px the search becomes a button, under 800px everything moves behind the menu button.
|
|
29
29
|
*/
|
|
30
30
|
compact?: boolean;
|
|
31
31
|
/** Controls the menu from outside; otherwise the bar keeps its own open state. */
|
|
@@ -51,6 +51,18 @@ export function Navbar({
|
|
|
51
51
|
const [ownOpen, setOwnOpen] = useState(false);
|
|
52
52
|
const open = menuOpen ?? ownOpen;
|
|
53
53
|
const toggle = onMenuToggle ?? (() => setOwnOpen((value) => !value));
|
|
54
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
55
|
+
// The search button opens the panel below the bar; the field there gets focus, so typing can start at once.
|
|
56
|
+
const focusSearch = useRef(false);
|
|
57
|
+
const openSearch = () => {
|
|
58
|
+
focusSearch.current = !open;
|
|
59
|
+
toggle();
|
|
60
|
+
};
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open || !focusSearch.current) return;
|
|
63
|
+
focusSearch.current = false;
|
|
64
|
+
menu.current?.querySelector("input")?.focus();
|
|
65
|
+
}, [open]);
|
|
54
66
|
|
|
55
67
|
const search = searchPlaceholder && (
|
|
56
68
|
<label className={styles.search}>
|
|
@@ -102,6 +114,18 @@ export function Navbar({
|
|
|
102
114
|
</nav>
|
|
103
115
|
<div className={styles.end}>
|
|
104
116
|
{search}
|
|
117
|
+
{searchPlaceholder && (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className={styles.searchButton}
|
|
121
|
+
aria-expanded={open}
|
|
122
|
+
aria-controls={menuId}
|
|
123
|
+
aria-label="Search"
|
|
124
|
+
onClick={openSearch}
|
|
125
|
+
>
|
|
126
|
+
<Icon name="search" size={18} />
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
105
129
|
{actions && <div className={styles.actions}>{actions}</div>}
|
|
106
130
|
{user && (
|
|
107
131
|
<span className={styles.user} title={user.name}>
|
|
@@ -111,7 +135,7 @@ export function Navbar({
|
|
|
111
135
|
</div>
|
|
112
136
|
</div>
|
|
113
137
|
{open && (
|
|
114
|
-
<div id={menuId} className={styles.menu}>
|
|
138
|
+
<div id={menuId} ref={menu} className={styles.menu}>
|
|
115
139
|
{search}
|
|
116
140
|
{actions && <div className={styles.actions}>{actions}</div>}
|
|
117
141
|
<nav className={styles.menuLinks} aria-label="Main">
|