@charcuterie/ui 4.9.0 → 4.10.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.
@@ -0,0 +1,319 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { type ReactNode, useState } from "react"
4
+
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ StorySection,
10
+ } from "../board.storyHelpers.tsx"
11
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
12
+ import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
13
+ import type { BreadcrumbItem } from "./Breadcrumbs.tsx"
14
+ import { Breadcrumbs } from "./Breadcrumbs.tsx"
15
+
16
+ /**
17
+ * A stand-in for react-router's `<Link>`, marked so `Routed` (and its
18
+ * test) can show which rungs the injected router was handed.
19
+ */
20
+ const SoftRouterLink = ({
21
+ href,
22
+ ...linkProps
23
+ }: RouterLinkProps): ReactNode => (
24
+ <a {...linkProps} data-router="soft" href={href} />
25
+ )
26
+
27
+ /** A document trail ending on the current page. */
28
+ const DOCUMENT_TRAIL: BreadcrumbItem[] = [
29
+ { href: "/", label: "Library" },
30
+ { href: "/handbook", label: "Handbook" },
31
+ { href: "/handbook/runbooks", label: "runbooks" },
32
+ { label: "restoring-a-snapshot.md" },
33
+ ]
34
+
35
+ /**
36
+ * A trail that sits above a heading which already names the page, so
37
+ * every rung is a link and none of them is current.
38
+ */
39
+ const PLACE_TRAIL: BreadcrumbItem[] = [
40
+ { href: "/places", label: "Places" },
41
+ { href: "/places/office", label: "Office" },
42
+ {
43
+ href: "/places/office/shelves",
44
+ label: "Office Shelves",
45
+ },
46
+ ]
47
+
48
+ const LONG_TRAIL: BreadcrumbItem[] = [
49
+ { href: "/", label: "Library" },
50
+ { href: "/archive", label: "Archive" },
51
+ { href: "/archive/2026", label: "2026" },
52
+ {
53
+ href: "/archive/2026/field-recordings",
54
+ label: "Field recordings",
55
+ },
56
+ {
57
+ label:
58
+ "a-very-long-file-name-with-no-spaces-at-all-0123456789abcdef.flac",
59
+ },
60
+ ]
61
+
62
+ const meta = {
63
+ title: "Components/Layout/Breadcrumbs",
64
+ component: Breadcrumbs,
65
+ parameters: { layout: "padded" },
66
+ // The component's own default, restated so the props table and the
67
+ // control agree.
68
+ args: {
69
+ items: DOCUMENT_TRAIL,
70
+ label: "Breadcrumb",
71
+ },
72
+ } satisfies Meta<typeof Breadcrumbs>
73
+
74
+ export default meta
75
+
76
+ type Story = StoryObj<typeof meta>
77
+
78
+ export const Playground: Story = {
79
+ parameters: playgroundParameters,
80
+ }
81
+
82
+ /**
83
+ * The shapes a trail takes. Each cell's landmark carries its own name,
84
+ * because four `<nav>`s all called "Breadcrumb" on one page is axe's
85
+ * `landmark-unique` — the same rule an app with two trails would hit.
86
+ */
87
+ export const AllVariants: Story = {
88
+ render: () => (
89
+ <StorySection title="The last rung with nowhere to go is the current page. Give every rung an href and none is current.">
90
+ <StoryGrid columns={2}>
91
+ <StoryCell
92
+ align="stretch"
93
+ label="ends on the current page"
94
+ >
95
+ <Breadcrumbs
96
+ items={DOCUMENT_TRAIL}
97
+ label="Document trail"
98
+ />
99
+ </StoryCell>
100
+
101
+ <StoryCell
102
+ align="stretch"
103
+ label="every rung a link (above a heading)"
104
+ >
105
+ <div className="flex flex-col gap-1">
106
+ <Breadcrumbs
107
+ items={PLACE_TRAIL.slice(0, -1)}
108
+ label="Place trail"
109
+ />
110
+ <h3 className="m-0 font-semibold text-content-primary text-lg">
111
+ Office Shelves
112
+ </h3>
113
+ </div>
114
+ </StoryCell>
115
+
116
+ <StoryCell
117
+ align="stretch"
118
+ label="onSelect rungs (no URL)"
119
+ >
120
+ <Breadcrumbs
121
+ items={[
122
+ { label: "G:", onSelect: () => {} },
123
+ { label: "Photos", onSelect: () => {} },
124
+ { label: "2026" },
125
+ ]}
126
+ label="Folder trail"
127
+ />
128
+ </StoryCell>
129
+
130
+ <StoryCell align="stretch" label='separator="/"'>
131
+ <Breadcrumbs
132
+ items={DOCUMENT_TRAIL}
133
+ label="Slash trail"
134
+ separator="/"
135
+ />
136
+ </StoryCell>
137
+
138
+ <StoryCell align="stretch" label="one rung">
139
+ <Breadcrumbs
140
+ items={[{ label: "Library" }]}
141
+ label="Root trail"
142
+ />
143
+ </StoryCell>
144
+
145
+ <StoryCell
146
+ align="stretch"
147
+ label="all links, last included"
148
+ >
149
+ <Breadcrumbs
150
+ items={PLACE_TRAIL}
151
+ label="Full place trail"
152
+ />
153
+ </StoryCell>
154
+ </StoryGrid>
155
+ </StorySection>
156
+ ),
157
+ }
158
+
159
+ /**
160
+ * Hover and focus, forced by the pseudo-states addon. A link rung and
161
+ * a button rung sit side by side in each state: they must be
162
+ * indistinguishable, because to the reader they are the same thing.
163
+ */
164
+ export const AllStates: Story = {
165
+ parameters: {
166
+ pseudo: {
167
+ focusVisible: [
168
+ "#focus-link a",
169
+ "#focus-button button",
170
+ ],
171
+ hover: ["#hover-link a", "#hover-button button"],
172
+ },
173
+ },
174
+ render: () => (
175
+ <StoryGrid columns={2}>
176
+ <StoryCell label="link · rest">
177
+ <Breadcrumbs
178
+ items={PLACE_TRAIL.slice(0, 1)}
179
+ label="Link at rest"
180
+ />
181
+ </StoryCell>
182
+
183
+ <StoryCell label="button · rest">
184
+ <Breadcrumbs
185
+ items={[{ label: "Places", onSelect: () => {} }]}
186
+ label="Button at rest"
187
+ />
188
+ </StoryCell>
189
+
190
+ <StoryCell label="link · hover (forced)">
191
+ <div id="hover-link">
192
+ <Breadcrumbs
193
+ items={PLACE_TRAIL.slice(0, 1)}
194
+ label="Link hovered"
195
+ />
196
+ </div>
197
+ </StoryCell>
198
+
199
+ <StoryCell label="button · hover (forced)">
200
+ <div id="hover-button">
201
+ <Breadcrumbs
202
+ items={[
203
+ { label: "Places", onSelect: () => {} },
204
+ ]}
205
+ label="Button hovered"
206
+ />
207
+ </div>
208
+ </StoryCell>
209
+
210
+ <StoryCell label="link · focus-visible (forced)">
211
+ <div id="focus-link">
212
+ <Breadcrumbs
213
+ items={PLACE_TRAIL.slice(0, 1)}
214
+ label="Link focused"
215
+ />
216
+ </div>
217
+ </StoryCell>
218
+
219
+ <StoryCell label="button · focus-visible (forced)">
220
+ <div id="focus-button">
221
+ <Breadcrumbs
222
+ items={[
223
+ { label: "Places", onSelect: () => {} },
224
+ ]}
225
+ label="Button focused"
226
+ />
227
+ </div>
228
+ </StoryCell>
229
+
230
+ <StoryCell label="current page">
231
+ <Breadcrumbs
232
+ items={[{ label: "Office Shelves" }]}
233
+ label="Current only"
234
+ />
235
+ </StoryCell>
236
+
237
+ <StoryCell label="plain text rung (not last)">
238
+ <Breadcrumbs
239
+ items={[
240
+ { label: "Places" },
241
+ { href: "/places/office", label: "Office" },
242
+ ]}
243
+ label="Text rung"
244
+ />
245
+ </StoryCell>
246
+ </StoryGrid>
247
+ ),
248
+ }
249
+
250
+ /**
251
+ * A trail wraps; it never truncates. The narrow panel breaks the
252
+ * row between rungs first and then, for the one segment with no
253
+ * spaces in it, inside the segment — so every word is still there.
254
+ */
255
+ export const Responsive: Story = {
256
+ render: () => (
257
+ <ContainerBoard>
258
+ {(width) => (
259
+ <Breadcrumbs
260
+ items={LONG_TRAIL}
261
+ label={`Trail at ${width}`}
262
+ />
263
+ )}
264
+ </ContainerBoard>
265
+ ),
266
+ }
267
+
268
+ /**
269
+ * The seam: an in-app `href` goes through the injected router, the
270
+ * same way `TextLink` routes, so a Charcuterie app wired with
271
+ * `ReactRouterAdapter` gets soft navigation with no extra prop.
272
+ */
273
+ export const Routed: Story = {
274
+ decorators: [
275
+ (Story) => (
276
+ <RouterLinkProvider link={SoftRouterLink}>
277
+ <Story />
278
+ </RouterLinkProvider>
279
+ ),
280
+ ],
281
+ args: { items: PLACE_TRAIL.slice(0, -1) },
282
+ }
283
+
284
+ const FOLDERS = ["G:", "Photos", "2026", "Garden"]
285
+
286
+ /**
287
+ * The keyboard path, and the state-driven trail image-viewer and
288
+ * mux-magic need: Tab walks the rungs in order, Enter or Space climbs
289
+ * to one, and the current folder is text, so Tab skips it.
290
+ */
291
+ export const Interactive: Story = {
292
+ render: () => {
293
+ const [depth, setDepth] = useState(FOLDERS.length)
294
+
295
+ const visible = FOLDERS.slice(0, depth)
296
+
297
+ return (
298
+ <div className="flex flex-col gap-3">
299
+ <Breadcrumbs
300
+ items={visible.map((folder, index) => ({
301
+ key: folder,
302
+ label: folder,
303
+ onSelect:
304
+ index === visible.length - 1
305
+ ? undefined
306
+ : () => {
307
+ setDepth(index + 1)
308
+ },
309
+ }))}
310
+ label="Folder trail"
311
+ />
312
+
313
+ <p className="m-0 text-content-secondary text-sm">
314
+ Showing {visible.join(" / ")}
315
+ </p>
316
+ </div>
317
+ )
318
+ },
319
+ }
@@ -0,0 +1,250 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Breadcrumbs.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Interactive,
14
+ Playground,
15
+ Responsive,
16
+ Routed,
17
+ } = composeStories(stories)
18
+
19
+ /**
20
+ * The APG breadcrumb pattern: a named `<nav>` landmark around an
21
+ * ordered list, one `listitem` per rung.
22
+ */
23
+ test("is a named navigation landmark around an ordered list", async () => {
24
+ const { canvas, canvasElement } =
25
+ await mountStory(Playground)
26
+
27
+ const nav = expectAgentDrivable(canvas, {
28
+ name: "Breadcrumb",
29
+ role: "navigation",
30
+ })
31
+
32
+ const list = nav.querySelector("ol")
33
+
34
+ await expect(list).not.toBeNull()
35
+ await expect(
36
+ canvas.getAllByRole("listitem"),
37
+ ).toHaveLength(4)
38
+
39
+ await expectNoAxeViolations(canvasElement)
40
+ })
41
+
42
+ test("every rung but the last is a link; the last is the current page", async () => {
43
+ const { canvas } = await mountStory(Playground)
44
+
45
+ const links = canvas.getAllByRole("link")
46
+
47
+ await expect(
48
+ links.map((link: HTMLElement) => link.textContent),
49
+ ).toEqual(["Library", "Handbook", "runbooks"])
50
+ await expect(links[1]).toHaveAttribute(
51
+ "href",
52
+ "/handbook",
53
+ )
54
+
55
+ const current = canvas.getByText(
56
+ "restoring-a-snapshot.md",
57
+ )
58
+
59
+ await expect(current.tagName).toBe("SPAN")
60
+ await expect(current).toHaveAttribute(
61
+ "aria-current",
62
+ "page",
63
+ )
64
+ // Exactly one current rung.
65
+ await expect(
66
+ canvas
67
+ .getByRole("navigation")
68
+ .querySelectorAll("[aria-current]"),
69
+ ).toHaveLength(1)
70
+ })
71
+
72
+ /**
73
+ * The Whereabouts shape: the trail sits above a heading that already
74
+ * names the page, so every rung links and nothing claims to be current.
75
+ */
76
+ test("a trail of links only has no current rung", async () => {
77
+ const { canvas, canvasElement } =
78
+ await mountStory(AllVariants)
79
+
80
+ const nav: HTMLElement = canvas.getByRole("navigation", {
81
+ name: "Full place trail",
82
+ })
83
+
84
+ await expect(nav.querySelectorAll("a")).toHaveLength(3)
85
+ await expect(
86
+ nav.querySelector("[aria-current]"),
87
+ ).toBeNull()
88
+
89
+ await expectNoAxeViolations(canvasElement)
90
+ })
91
+
92
+ test("the separator is decorative", async () => {
93
+ const { canvas } = await mountStory(AllVariants)
94
+
95
+ const nav: HTMLElement = canvas.getByRole("navigation", {
96
+ name: "Slash trail",
97
+ })
98
+
99
+ // Three separators between four rungs, all hidden, none before the
100
+ // first rung.
101
+ const separators = nav.querySelectorAll(
102
+ '[aria-hidden="true"]',
103
+ )
104
+
105
+ await expect(separators).toHaveLength(3)
106
+ await expect(
107
+ nav.querySelector("li")?.querySelector("[aria-hidden]"),
108
+ ).toBeNull()
109
+ // The slash never reaches a rung's name.
110
+ await expect(
111
+ nav.querySelector('a[href="/handbook"]'),
112
+ ).toHaveAccessibleName("Handbook")
113
+ })
114
+
115
+ /**
116
+ * A link rung and a button rung are the same thing to a reader, so
117
+ * they are painted the same: same colour, same weight.
118
+ */
119
+ test("a button rung is painted as a link rung", async () => {
120
+ const { canvas, canvasElement } =
121
+ await mountStory(AllStates)
122
+
123
+ const link = canvas.getAllByRole("link", {
124
+ name: "Places",
125
+ })[0]
126
+
127
+ const button = canvas.getAllByRole("button", {
128
+ name: "Places",
129
+ })[0]
130
+
131
+ if (link === undefined || button === undefined) {
132
+ throw new Error("Both rungs must render.")
133
+ }
134
+
135
+ await expect(getComputedStyle(button).color).toBe(
136
+ getComputedStyle(link).color,
137
+ )
138
+ await expect(getComputedStyle(button).fontWeight).toBe(
139
+ getComputedStyle(link).fontWeight,
140
+ )
141
+ await expect(
142
+ getComputedStyle(button).backgroundColor,
143
+ ).toBe("rgba(0, 0, 0, 0)")
144
+
145
+ await expectNoAxeViolations(canvasElement)
146
+ })
147
+
148
+ /**
149
+ * Wraps, never truncates: at the narrowest panel every rung's full
150
+ * text is still in the layout, and no rung is wider than the trail.
151
+ */
152
+ test("a long trail wraps inside a narrow container and hides nothing", async () => {
153
+ const { canvas, canvasElement } =
154
+ await mountStory(Responsive)
155
+
156
+ const nav: HTMLElement = canvas.getByRole("navigation", {
157
+ name: "Trail at 15rem",
158
+ })
159
+
160
+ const navWidth = nav.getBoundingClientRect().width
161
+
162
+ for (const item of Array.from(
163
+ nav.querySelectorAll("li"),
164
+ )) {
165
+ await expect(
166
+ item.getBoundingClientRect().width,
167
+ ).toBeLessThanOrEqual(navWidth + 0.5)
168
+ }
169
+
170
+ const fileName = canvas.getAllByText(
171
+ /a-very-long-file-name/,
172
+ )[0]
173
+
174
+ if (fileName === undefined) {
175
+ throw new Error("The file rung must render.")
176
+ }
177
+
178
+ await expect(
179
+ getComputedStyle(fileName).textOverflow,
180
+ ).not.toBe("ellipsis")
181
+ await expect(fileName.scrollWidth).toBeLessThanOrEqual(
182
+ fileName.clientWidth + 1,
183
+ )
184
+ // More than one line: the list wrapped.
185
+ await expect(
186
+ nav.querySelector("ol")?.getBoundingClientRect().height,
187
+ ).toBeGreaterThan(30)
188
+
189
+ await expectNoAxeViolations(canvasElement)
190
+ })
191
+
192
+ test("an in-app href goes through the injected router", async () => {
193
+ const { canvas } = await mountStory(Routed)
194
+
195
+ await expect(
196
+ canvas.getByRole("link", { name: "Places" }),
197
+ ).toHaveAttribute("data-router", "soft")
198
+ })
199
+
200
+ /**
201
+ * Tab walks the rungs in order and skips the current folder, which is
202
+ * text. Enter climbs to a rung; Space does the same on the next one.
203
+ */
204
+ test("the keyboard walks and climbs the trail", async () => {
205
+ const { canvas, canvasElement } =
206
+ await mountStory(Interactive)
207
+
208
+ await expect(
209
+ canvas
210
+ .getAllByRole("button")
211
+ .map((button: HTMLElement) => button.textContent),
212
+ ).toEqual(["G:", "Photos", "2026"])
213
+
214
+ await userEvent.tab()
215
+ await expect(
216
+ canvas.getByRole("button", { name: "G:" }),
217
+ ).toHaveFocus()
218
+
219
+ await userEvent.tab()
220
+ await userEvent.tab()
221
+ await expect(
222
+ canvas.getByRole("button", { name: "2026" }),
223
+ ).toHaveFocus()
224
+
225
+ await userEvent.keyboard("{Enter}")
226
+
227
+ await expect(
228
+ canvas.getByText("2026").closest("[aria-current]"),
229
+ ).toHaveAttribute("aria-current", "page")
230
+ await expect(
231
+ canvas.queryByText("Garden", { selector: "li *" }),
232
+ ).toBeNull()
233
+
234
+ // The pressed rung became text and left the tab order, so Tab
235
+ // starts again from the top.
236
+ await userEvent.tab()
237
+ await userEvent.tab()
238
+ await expect(
239
+ canvas.getByRole("button", { name: "Photos" }),
240
+ ).toHaveFocus()
241
+
242
+ await userEvent.keyboard(" ")
243
+
244
+ await expect(canvas.getByText("Photos")).toHaveAttribute(
245
+ "aria-current",
246
+ "page",
247
+ )
248
+
249
+ await expectNoAxeViolations(canvasElement)
250
+ })