@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,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
+ })
@@ -0,0 +1,179 @@
1
+ import type { Key, ReactNode } from "react"
2
+
3
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
4
+ import { TextLink } from "../TextLink/TextLink.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ /**
8
+ * One rung of the trail.
9
+ *
10
+ * Which of the three it becomes is read off the fields it carries, so
11
+ * a caller mapping a path to items never has to say what the rung
12
+ * *is* — only where it goes:
13
+ *
14
+ * - `href` — a real `<a href>`, through `TextLink`, so an in-app path
15
+ * goes through the injected router exactly as every other link in
16
+ * the library does.
17
+ * - `onSelect` and no `href` — a `<button>` painted as the same link.
18
+ * For a trail over state that has no URL: image-viewer's folder
19
+ * path and mux-magic's file-explorer modal both move a browser
20
+ * around a disk without touching the address bar.
21
+ * - neither — plain text. On the **last** item that is the current
22
+ * page, and it carries `aria-current="page"`.
23
+ */
24
+ export type BreadcrumbItem = {
25
+ href?: string
26
+ /**
27
+ * React's list key. Optional, because `label` is a node and cannot
28
+ * be one; the fallback is `href`, then the position. Pass it when
29
+ * two rungs can share an `href` or the trail reorders.
30
+ */
31
+ key?: Key
32
+ label: ReactNode
33
+ /**
34
+ * Runs on activation. With an `href` it runs alongside the
35
+ * navigation (closing a drawer, say); without one it IS the
36
+ * navigation.
37
+ */
38
+ onSelect?: () => void
39
+ }
40
+
41
+ export type BreadcrumbsProps = {
42
+ className?: string
43
+ items: readonly BreadcrumbItem[]
44
+ /**
45
+ * The `<nav>` landmark's name. Two trails on one page need two
46
+ * different names, or axe's `landmark-unique` fails.
47
+ */
48
+ label?: string
49
+ /**
50
+ * Drawn between rungs, always `aria-hidden`. The default is a
51
+ * chevron drawn in CSS borders rather than a `›` character: the
52
+ * library ships no symbol glyphs in a default, because a font that
53
+ * lacks one paints nothing
54
+ * (`docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md`).
55
+ * Folio's `/` is a fine override — it is the app's choice to make.
56
+ */
57
+ separator?: ReactNode
58
+ }
59
+
60
+ /**
61
+ * The link paint without the `<a>`: `TextLink`'s standalone look,
62
+ * restated for the `onSelect` rung so a button crumb and a link crumb
63
+ * are indistinguishable at rest, on hover, and under focus.
64
+ */
65
+ const BUTTON_CRUMB_CLASS =
66
+ "inline cursor-pointer rounded-xs border-0 bg-transparent p-0 text-start font-medium text-intent-accent-content underline-offset-2 hover:underline"
67
+
68
+ /**
69
+ * `wrap-anywhere` on every label, and no `truncate`: a trail that cuts
70
+ * a folder name to `Office Sh…` hides the one word the reader came to
71
+ * find. A long unbroken segment — a hash, a file name — breaks inside
72
+ * itself instead of pushing the row wider than its container.
73
+ */
74
+ const LABEL_CLASS = "min-w-0 wrap-anywhere"
75
+
76
+ /**
77
+ * Right-pointing in a left-to-right page, left-pointing in a
78
+ * right-to-left one: the two borders are logical (`border-e`), and the
79
+ * rotation flips with the direction so the corner always points the
80
+ * way the trail reads.
81
+ */
82
+ const DEFAULT_SEPARATOR = (
83
+ <span className="inline-block size-1.5 shrink-0 rotate-45 border-e-[1.5px] border-t-[1.5px] border-current rtl:-rotate-45" />
84
+ )
85
+
86
+ const getItemKey = (
87
+ item: BreadcrumbItem,
88
+ index: number,
89
+ ): Key => item.key ?? item.href ?? index
90
+
91
+ /**
92
+ * A path back up a hierarchy: `Places › Office › Office Shelves`.
93
+ *
94
+ * Folio, image-viewer and mux-magic each hand-rolled one, and they
95
+ * disagreed on everything a shared shape should settle — one was a
96
+ * `<nav>` of bare links with no list, two were `<div>`s of buttons
97
+ * with no landmark, one truncated the current segment, one gave the
98
+ * current folder no `aria-current`. This is the WAI-ARIA APG
99
+ * breadcrumb pattern: a `<nav>` landmark around an `<ol>`, because
100
+ * the order is the meaning.
101
+ *
102
+ * The current page is the **last item with nowhere to go**. A trail
103
+ * that sits above a heading already naming the page — Whereabouts'
104
+ * `Places › Office` over an `Office Shelves` heading — gives every
105
+ * item an `href`, and then no rung is current, which is correct: the
106
+ * page is named once, by the heading.
107
+ */
108
+ export const Breadcrumbs = ({
109
+ className,
110
+ items,
111
+ label = "Breadcrumb",
112
+ separator,
113
+ }: BreadcrumbsProps): ReactNode => (
114
+ <nav aria-label={label} className={className}>
115
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-content-secondary text-sm">
116
+ {items.map((item, index) => {
117
+ const isLast = index === items.length - 1
118
+
119
+ const isCurrent =
120
+ isLast &&
121
+ item.href === undefined &&
122
+ item.onSelect === undefined
123
+
124
+ return (
125
+ <li
126
+ className="inline-flex min-w-0 items-center gap-x-2"
127
+ key={getItemKey(item, index)}
128
+ >
129
+ {index > 0 ? (
130
+ <span
131
+ aria-hidden="true"
132
+ className="inline-flex shrink-0 items-center text-content-muted"
133
+ >
134
+ {separator ?? DEFAULT_SEPARATOR}
135
+ </span>
136
+ ) : null}
137
+
138
+ {item.href !== undefined ? (
139
+ <TextLink
140
+ appearance="standalone"
141
+ className={LABEL_CLASS}
142
+ href={item.href}
143
+ onClick={item.onSelect}
144
+ >
145
+ {item.label}
146
+ </TextLink>
147
+ ) : item.onSelect !== undefined ? (
148
+ <button
149
+ className={toClassName(
150
+ BUTTON_CRUMB_CLASS,
151
+ FOCUS_RING_CLASS,
152
+ LABEL_CLASS,
153
+ )}
154
+ onClick={item.onSelect}
155
+ type="button"
156
+ >
157
+ {item.label}
158
+ </button>
159
+ ) : (
160
+ <span
161
+ aria-current={
162
+ isCurrent ? "page" : undefined
163
+ }
164
+ className={toClassName(
165
+ LABEL_CLASS,
166
+ isCurrent
167
+ ? "font-medium text-content-primary"
168
+ : undefined,
169
+ )}
170
+ >
171
+ {item.label}
172
+ </span>
173
+ )}
174
+ </li>
175
+ )
176
+ })}
177
+ </ol>
178
+ </nav>
179
+ )
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ export type {
37
37
  AccordionProps,
38
38
  } from "./Accordion/Accordion.tsx"
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
+ export type { ActionMenuProps } from "./ActionMenu/ActionMenu.tsx"
41
+ export { ActionMenu } from "./ActionMenu/ActionMenu.tsx"
40
42
  export type {
41
43
  ActionTileItem,
42
44
  ActionTilesAccent,
@@ -99,6 +101,11 @@ export {
99
101
  getIsMoveMeaningful,
100
102
  toSettledIndex,
101
103
  } from "./Board/boardMove.ts"
104
+ export type {
105
+ BreadcrumbItem,
106
+ BreadcrumbsProps,
107
+ } from "./Breadcrumbs/Breadcrumbs.tsx"
108
+ export { Breadcrumbs } from "./Breadcrumbs/Breadcrumbs.tsx"
102
109
  export type { ButtonProps } from "./Button/Button.tsx"
103
110
  export { Button } from "./Button/Button.tsx"
104
111
  export type { ButtonLinkProps } from "./ButtonLink/ButtonLink.tsx"
@@ -615,7 +615,9 @@ test("the barrel is the only place components are re-exported", async () => {
615
615
  // question the same way the bars are placed, or the two answers
616
616
  // drift by a day and nothing says so.
617
617
  // ProgressCard composes Card and ProgressBar for progress-first dashboards.
618
- expect(componentNames.length).toBe(67)
618
+ // Breadcrumbs composes TextLink for the path back up a hierarchy.
619
+ // ActionMenu composes IconButton and Menu for the overflow control.
620
+ expect(componentNames.length).toBe(69)
619
621
 
620
622
  for (const name of componentNames) {
621
623
  expect(barrel).toContain(`export { ${name} }`)