@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,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} />