@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.
- 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/dist/markdownEditorCodeMirror/livePreview.d.ts +11 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +29 -14
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/package.json +1 -1
- 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/markdownEditorCodeMirror/livePreview.test.ts +88 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +45 -20
- package/src/sourceRules.test.ts +3 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
|
|
4
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
5
|
+
import {
|
|
6
|
+
ContainerBoard,
|
|
7
|
+
StoryCell,
|
|
8
|
+
StoryGrid,
|
|
9
|
+
StorySection,
|
|
10
|
+
} from "../board.storyHelpers.tsx"
|
|
11
|
+
import { Card } from "../Card/Card.tsx"
|
|
12
|
+
import {
|
|
13
|
+
MoreIcon,
|
|
14
|
+
RedoIcon,
|
|
15
|
+
SettingsIcon,
|
|
16
|
+
UndoIcon,
|
|
17
|
+
} from "../icons.storyHelpers.tsx"
|
|
18
|
+
import type { MenuEntry } from "../Menu/Menu.tsx"
|
|
19
|
+
import { ActionMenu } from "./ActionMenu.tsx"
|
|
20
|
+
|
|
21
|
+
const noop = () => undefined
|
|
22
|
+
|
|
23
|
+
const SHELF_ACTIONS: MenuEntry[] = [
|
|
24
|
+
{
|
|
25
|
+
icon: <UndoIcon />,
|
|
26
|
+
key: "rename",
|
|
27
|
+
label: "Rename",
|
|
28
|
+
onSelect: noop,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
icon: <RedoIcon />,
|
|
32
|
+
key: "move",
|
|
33
|
+
label: "Move to another place",
|
|
34
|
+
onSelect: noop,
|
|
35
|
+
},
|
|
36
|
+
{ key: "divider", type: "separator" },
|
|
37
|
+
{
|
|
38
|
+
icon: <SettingsIcon />,
|
|
39
|
+
isDisabled: true,
|
|
40
|
+
key: "archive",
|
|
41
|
+
label: "Archive",
|
|
42
|
+
onSelect: noop,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const meta = {
|
|
47
|
+
title: "Components/Actions/ActionMenu",
|
|
48
|
+
component: ActionMenu,
|
|
49
|
+
parameters: { layout: "padded" },
|
|
50
|
+
argTypes: { size: controlSizeArgType },
|
|
51
|
+
args: {
|
|
52
|
+
items: SHELF_ACTIONS,
|
|
53
|
+
label: "Actions for Office Shelves",
|
|
54
|
+
size: "md",
|
|
55
|
+
},
|
|
56
|
+
} satisfies Meta<typeof ActionMenu>
|
|
57
|
+
|
|
58
|
+
export default meta
|
|
59
|
+
|
|
60
|
+
type Story = StoryObj<typeof meta>
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* At the end of its row, where it lives: the panel opens at
|
|
64
|
+
* `bottom-end`, so its end edge lines up with the button's and it
|
|
65
|
+
* opens inward.
|
|
66
|
+
*/
|
|
67
|
+
export const Playground: Story = {
|
|
68
|
+
parameters: playgroundParameters,
|
|
69
|
+
render: (actionMenuProps) => (
|
|
70
|
+
<div className="flex justify-end">
|
|
71
|
+
<ActionMenu {...actionMenuProps} />
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The default dots against an app's own glyph, at every size. The
|
|
78
|
+
* dots are three round boxes, not a `⋮` and not an SVG, so they draw
|
|
79
|
+
* in a font that has no such character.
|
|
80
|
+
*/
|
|
81
|
+
export const AllVariants: Story = {
|
|
82
|
+
render: (actionMenuProps) => (
|
|
83
|
+
<StorySection title="Default dots or the app's own icon, at sm, md and lg. Every trigger names the thing it acts on.">
|
|
84
|
+
<StoryGrid columns={3}>
|
|
85
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
86
|
+
<StoryCell key={size} label={`size=${size}`}>
|
|
87
|
+
<ActionMenu
|
|
88
|
+
{...actionMenuProps}
|
|
89
|
+
label={`Actions for shelf ${size}`}
|
|
90
|
+
size={size}
|
|
91
|
+
/>
|
|
92
|
+
</StoryCell>
|
|
93
|
+
))}
|
|
94
|
+
|
|
95
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
96
|
+
<StoryCell
|
|
97
|
+
key={`icon-${size}`}
|
|
98
|
+
label={`icon={<MoreIcon />} size=${size}`}
|
|
99
|
+
>
|
|
100
|
+
<ActionMenu
|
|
101
|
+
{...actionMenuProps}
|
|
102
|
+
icon={<MoreIcon />}
|
|
103
|
+
label={`Actions for bin ${size}`}
|
|
104
|
+
size={size}
|
|
105
|
+
/>
|
|
106
|
+
</StoryCell>
|
|
107
|
+
))}
|
|
108
|
+
</StoryGrid>
|
|
109
|
+
</StorySection>
|
|
110
|
+
),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rest, hover and focus on the trigger. The open state is `Menu`'s
|
|
115
|
+
* own board; this component adds nothing to the panel.
|
|
116
|
+
*/
|
|
117
|
+
export const AllStates: Story = {
|
|
118
|
+
parameters: {
|
|
119
|
+
pseudo: {
|
|
120
|
+
focusVisible: ["#focus-target button"],
|
|
121
|
+
hover: ["#hover-target button"],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
render: (actionMenuProps) => (
|
|
125
|
+
<StoryGrid columns={3}>
|
|
126
|
+
<StoryCell label="rest">
|
|
127
|
+
<ActionMenu
|
|
128
|
+
{...actionMenuProps}
|
|
129
|
+
label="Actions for Cables"
|
|
130
|
+
/>
|
|
131
|
+
</StoryCell>
|
|
132
|
+
|
|
133
|
+
<StoryCell label="hover (forced)">
|
|
134
|
+
<div id="hover-target">
|
|
135
|
+
<ActionMenu
|
|
136
|
+
{...actionMenuProps}
|
|
137
|
+
label="Actions for Adapters"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
</StoryCell>
|
|
141
|
+
|
|
142
|
+
<StoryCell label="focus-visible (forced)">
|
|
143
|
+
<div id="focus-target">
|
|
144
|
+
<ActionMenu
|
|
145
|
+
{...actionMenuProps}
|
|
146
|
+
label="Actions for Chargers"
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</StoryCell>
|
|
150
|
+
</StoryGrid>
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where it lives: at the end of a card's header row. The title wraps
|
|
156
|
+
* and the trigger keeps its size, at every container width.
|
|
157
|
+
*/
|
|
158
|
+
export const Responsive: Story = {
|
|
159
|
+
render: (actionMenuProps) => (
|
|
160
|
+
<ContainerBoard>
|
|
161
|
+
{(width) => (
|
|
162
|
+
<Card>
|
|
163
|
+
<div className="flex items-start justify-between gap-2">
|
|
164
|
+
<h3 className="m-0 min-w-0 font-semibold text-content-primary text-md wrap-anywhere">
|
|
165
|
+
Office Shelves, the tall unit by the window
|
|
166
|
+
</h3>
|
|
167
|
+
<ActionMenu
|
|
168
|
+
{...actionMenuProps}
|
|
169
|
+
label={`Actions for Office Shelves at ${width}`}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</Card>
|
|
173
|
+
)}
|
|
174
|
+
</ContainerBoard>
|
|
175
|
+
),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Enter or Space opens it and focus moves to the first item; the
|
|
180
|
+
* arrows walk the items and skip the disabled one; choosing an item
|
|
181
|
+
* or pressing Escape closes it.
|
|
182
|
+
*/
|
|
183
|
+
export const Interactive: Story = {}
|
|
@@ -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} />
|