@charcuterie/ui 4.9.1 → 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,187 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } 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 "./ActionMenu.stories.tsx"
9
+
10
+ const { AllVariants, Interactive, Playground, Responsive } =
11
+ composeStories(stories)
12
+
13
+ test("the trigger is a button named for the thing it acts on", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Playground)
16
+
17
+ const trigger = expectAgentDrivable(canvas, {
18
+ name: "Actions for Office Shelves",
19
+ role: "button",
20
+ })
21
+
22
+ await expect(trigger).toHaveAttribute(
23
+ "aria-haspopup",
24
+ "menu",
25
+ )
26
+ await expect(trigger).toHaveAttribute(
27
+ "aria-expanded",
28
+ "false",
29
+ )
30
+
31
+ await expectNoAxeViolations(canvasElement)
32
+ })
33
+
34
+ test("clicking opens a menu named by the trigger, aligned to its end edge", async () => {
35
+ const { body, canvas } = await mountStory(Playground)
36
+
37
+ const trigger = canvas.getByRole("button", {
38
+ name: "Actions for Office Shelves",
39
+ })
40
+
41
+ await userEvent.click(trigger)
42
+
43
+ const menu = await waitFor(() =>
44
+ expectAgentDrivable(body, {
45
+ name: "Actions for Office Shelves",
46
+ role: "menu",
47
+ }),
48
+ )
49
+
50
+ await expect(trigger).toHaveAttribute(
51
+ "aria-expanded",
52
+ "true",
53
+ )
54
+
55
+ // `bottom-end`: the panel hangs from the trigger's end edge, so a
56
+ // trigger in a row's last column opens inward, not off screen. Not
57
+ // an exact edge match — this trigger touches the viewport, and
58
+ // `shift` keeps the panel its padding away from that edge.
59
+ const triggerBox = trigger.getBoundingClientRect()
60
+ const menuBox = menu.getBoundingClientRect()
61
+
62
+ await expect(menuBox.left).toBeLessThan(triggerBox.left)
63
+ await expect(menuBox.right).toBeLessThanOrEqual(
64
+ triggerBox.right + 1,
65
+ )
66
+ await expect(menuBox.top).toBeGreaterThanOrEqual(
67
+ triggerBox.bottom,
68
+ )
69
+
70
+ await expectNoAxeViolations(menu)
71
+
72
+ // A second click on the trigger closes it again.
73
+ await userEvent.click(trigger)
74
+
75
+ await waitFor(() => {
76
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
77
+ })
78
+ })
79
+
80
+ test("the keyboard opens it, walks it, and closes it", async () => {
81
+ const { body, canvas } = await mountStory(Interactive)
82
+
83
+ await userEvent.tab()
84
+ await expect(
85
+ canvas.getByRole("button", {
86
+ name: "Actions for Office Shelves",
87
+ }),
88
+ ).toHaveFocus()
89
+
90
+ await userEvent.keyboard("{Enter}")
91
+
92
+ await waitFor(() => {
93
+ expect(
94
+ body.getByRole("menuitem", { name: "Rename" }),
95
+ ).toHaveFocus()
96
+ })
97
+
98
+ await userEvent.keyboard("{ArrowDown}")
99
+ await expect(
100
+ body.getByRole("menuitem", {
101
+ name: "Move to another place",
102
+ }),
103
+ ).toHaveFocus()
104
+
105
+ await userEvent.keyboard("{Escape}")
106
+
107
+ await waitFor(() => {
108
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
109
+ })
110
+ })
111
+
112
+ test("choosing an item runs it and closes the menu", async () => {
113
+ const { body, canvas } = await mountStory(Interactive)
114
+
115
+ await userEvent.click(
116
+ canvas.getByRole("button", {
117
+ name: "Actions for Office Shelves",
118
+ }),
119
+ )
120
+
121
+ await userEvent.click(
122
+ await waitFor(() =>
123
+ body.getByRole("menuitem", { name: "Rename" }),
124
+ ),
125
+ )
126
+
127
+ await waitFor(() => {
128
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
129
+ })
130
+ })
131
+
132
+ /**
133
+ * The default dots are three boxes, so they have a size to measure in
134
+ * a font with no `⋮`. A custom icon replaces them.
135
+ */
136
+ test("the default dots draw without a font; an icon replaces them", async () => {
137
+ const { canvas, canvasElement } =
138
+ await mountStory(AllVariants)
139
+
140
+ const withDots = canvas.getByRole("button", {
141
+ name: "Actions for shelf md",
142
+ })
143
+
144
+ const dots = withDots.querySelectorAll(
145
+ "span > span.rounded-full",
146
+ )
147
+
148
+ await expect(dots).toHaveLength(3)
149
+
150
+ for (const dot of Array.from<Element>(dots)) {
151
+ await expect(
152
+ dot.getBoundingClientRect().width,
153
+ ).toBeGreaterThan(0)
154
+ }
155
+
156
+ await expect(withDots.querySelector("svg")).toBeNull()
157
+
158
+ const withIcon = canvas.getByRole("button", {
159
+ name: "Actions for bin md",
160
+ })
161
+
162
+ await expect(withIcon.querySelector("svg")).not.toBeNull()
163
+ await expect(
164
+ withIcon.querySelectorAll("span.rounded-full"),
165
+ ).toHaveLength(0)
166
+
167
+ await expectNoAxeViolations(canvasElement)
168
+ })
169
+
170
+ test("the trigger keeps its size beside a wrapping title", async () => {
171
+ const { canvas, canvasElement } =
172
+ await mountStory(Responsive)
173
+
174
+ const narrow: HTMLElement = canvas.getByRole("button", {
175
+ name: "Actions for Office Shelves at 15rem",
176
+ })
177
+
178
+ const wide: HTMLElement = canvas.getByRole("button", {
179
+ name: "Actions for Office Shelves at 34rem",
180
+ })
181
+
182
+ await expect(narrow.getBoundingClientRect().width).toBe(
183
+ wide.getBoundingClientRect().width,
184
+ )
185
+
186
+ await expectNoAxeViolations(canvasElement)
187
+ })
@@ -0,0 +1,96 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { IconButton } from "../IconButton/IconButton.tsx"
6
+ import type { MenuEntry } from "../Menu/Menu.tsx"
7
+ import { Menu } from "../Menu/Menu.tsx"
8
+
9
+ export type ActionMenuProps = {
10
+ /**
11
+ * Replaces the default dots. Pass the app's own glyph to match an
12
+ * icon set exactly — Docket and Whereabouts both use lucide's
13
+ * `EllipsisVertical` at `strokeWidth={1.75}`.
14
+ */
15
+ icon?: ReactNode
16
+ items: MenuEntry[]
17
+ /**
18
+ * The trigger's accessible name, and through it the menu's. Name
19
+ * the THING, not the gesture: `Actions for Cables`, so a page with
20
+ * twenty of these has twenty different buttons.
21
+ */
22
+ label: string
23
+ size?: ControlSize
24
+ }
25
+
26
+ /**
27
+ * Three dots stacked, drawn as three round boxes sized in `em` so they grow with the trigger's `size`, rather than a `⋮`
28
+ * character or an SVG. The library ships neither a glyph in a default
29
+ * — a font without `⋮` paints nothing — nor an icon asset
30
+ * (`docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md`).
31
+ * `currentColor` fill, so it takes the trigger's intent and its hover.
32
+ */
33
+ const DEFAULT_DOTS = (
34
+ <span
35
+ aria-hidden="true"
36
+ className="flex flex-col items-center gap-[0.17em]"
37
+ >
38
+ <span className="block size-[0.17em] rounded-full bg-current" />
39
+ <span className="block size-[0.17em] rounded-full bg-current" />
40
+ <span className="block size-[0.17em] rounded-full bg-current" />
41
+ </span>
42
+ )
43
+
44
+ /**
45
+ * The overflow control: a thing's secondary actions behind one
46
+ * labelled vertical-dots button, opening a `Menu` aligned to the
47
+ * button's end edge.
48
+ *
49
+ * Docket's `TaskActionMenu` and Whereabouts' `PlaceActionsMenu` were
50
+ * the same fifty lines — a ghost neutral `IconButton`, a `useState`
51
+ * for visibility, `placement="bottom-end"`, a toggle on click and a
52
+ * close on dismiss. This owns all of it, so the caller hands over
53
+ * only the actions and a name.
54
+ *
55
+ * The open state is internal on purpose: nothing outside the button
56
+ * has a reason to open it, and choosing an item, Escape and an
57
+ * outside press already close it through `Menu`'s `onDismiss`.
58
+ */
59
+ export const ActionMenu = ({
60
+ icon,
61
+ items,
62
+ label,
63
+ size,
64
+ }: ActionMenuProps): ReactNode => {
65
+ const [isVisible, setIsVisible] = useState(false)
66
+
67
+ return (
68
+ <Menu
69
+ isVisible={isVisible}
70
+ items={items}
71
+ onDismiss={() => {
72
+ setIsVisible(false)
73
+ }}
74
+ placement="bottom-end"
75
+ trigger={
76
+ <IconButton
77
+ appearance="ghost"
78
+ // A trigger at the end of a flex row with a long title
79
+ // beside it is where this lives, and a flex item shrinks by
80
+ // default — measured at 23px of a 40px button.
81
+ className="shrink-0"
82
+ intent="neutral"
83
+ label={label}
84
+ onClick={() => {
85
+ setIsVisible(
86
+ (isCurrentlyVisible) => !isCurrentlyVisible,
87
+ )
88
+ }}
89
+ size={size}
90
+ >
91
+ {icon ?? DEFAULT_DOTS}
92
+ </IconButton>
93
+ }
94
+ />
95
+ )
96
+ }
@@ -0,0 +1,71 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as BreadcrumbsStories from "./Breadcrumbs.stories";
4
+
5
+ <Meta of={BreadcrumbsStories} />
6
+
7
+ # Breadcrumbs
8
+
9
+ A path back up a hierarchy: `Places › Office › Office Shelves`.
10
+
11
+ Folio, image-viewer and mux-magic each hand-rolled one, and no two agreed on the landmark,
12
+ the list, the current page or what happens to a long name. This is the WAI-ARIA APG
13
+ breadcrumb pattern — a named `<nav>` around an `<ol>` — with the link paint of `TextLink`.
14
+
15
+ <Canvas of={BreadcrumbsStories.Playground} />
16
+
17
+ ## Props
18
+
19
+ <Controls />
20
+
21
+ ## What each rung becomes
22
+
23
+ A rung is read off the fields it carries. The caller says where it goes, never what it is.
24
+
25
+ | Item | Renders | Use it for |
26
+ | --- | --- | --- |
27
+ | `{ label, href }` | a `TextLink` — a real `<a href>`, routed through `RouterLinkProvider` | a trail over URLs: Folio, Whereabouts |
28
+ | `{ label, onSelect }` | a `<button>` painted as the same link | a trail over state with no URL: image-viewer's folders, mux-magic's file explorer |
29
+ | `{ label }`, last | text with `aria-current="page"` | the current page |
30
+ | `{ label }`, not last | plain text | a level that has no page of its own |
31
+
32
+ `href` and `onSelect` together render the link and run `onSelect` on click as well.
33
+
34
+ <Canvas of={BreadcrumbsStories.AllVariants} />
35
+
36
+ ## A trail above a heading has no current rung
37
+
38
+ When the page already names itself in a heading, the trail stops at the parent and every
39
+ rung is a link. Nothing gets `aria-current`, because the heading is the current page's
40
+ name and it should be announced once.
41
+
42
+ ```tsx
43
+ <Breadcrumbs
44
+ items={[
45
+ { href: "/places", label: "Places" },
46
+ { href: "/places/office", label: "Office" },
47
+ ]}
48
+ />
49
+ <h1>Office Shelves</h1>
50
+ ```
51
+
52
+ ## It wraps, and it never truncates
53
+
54
+ A long trail wraps between rungs, and a rung with no spaces breaks inside itself. Nothing
55
+ is cut to `Office Sh…`: the word that would be cut is the one the reader came to find.
56
+
57
+ <Canvas of={BreadcrumbsStories.Responsive} />
58
+
59
+ ## The separator
60
+
61
+ The default is a chevron drawn in CSS borders, not a `›` character — the library ships no
62
+ symbol glyphs in a default, because a font that lacks one paints nothing. It points the
63
+ way the trail reads in a right-to-left page too. Pass `separator` to use your own; it is
64
+ always `aria-hidden`.
65
+
66
+ ## Keyboard
67
+
68
+ Tab walks the rungs in order. Enter follows a link; Enter or Space presses a button rung.
69
+ The current page is text, so Tab skips it.
70
+
71
+ <Canvas of={BreadcrumbsStories.Interactive} />
@@ -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
+ }