@charcuterie/ui 4.9.1 → 4.10.1
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/dist/ActionMenu/ActionMenu.d.ts +36 -0
- package/dist/ActionMenu/ActionMenu.d.ts.map +1 -0
- package/dist/ActionMenu/ActionMenu.js +40 -0
- package/dist/ActionMenu/ActionMenu.js.map +1 -0
- package/dist/Breadcrumbs/Breadcrumbs.d.ts +71 -0
- package/dist/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/Breadcrumbs/Breadcrumbs.js +52 -0
- package/dist/Breadcrumbs/Breadcrumbs.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/ActionMenu/ActionMenu.mdx +51 -0
- package/src/ActionMenu/ActionMenu.stories.tsx +183 -0
- package/src/ActionMenu/ActionMenu.test.tsx +187 -0
- package/src/ActionMenu/ActionMenu.tsx +96 -0
- package/src/Breadcrumbs/Breadcrumbs.mdx +71 -0
- package/src/Breadcrumbs/Breadcrumbs.stories.tsx +319 -0
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +250 -0
- package/src/Breadcrumbs/Breadcrumbs.tsx +179 -0
- package/src/index.ts +7 -0
- package/src/sourceRules.test.ts +3 -1
|
@@ -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
|
+
}
|