@charcuterie/ui 3.21.0 → 3.22.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,350 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+
4
+ import {
5
+ controlSizeArgType,
6
+ placementArgType,
7
+ } from "../argTypes.storyHelpers.ts"
8
+ import { Button } from "../Button/Button.tsx"
9
+ import {
10
+ ContainerBoard,
11
+ StoryCell,
12
+ StoryGrid,
13
+ } from "../board.storyHelpers.tsx"
14
+ import { Header } from "../Header/Header.tsx"
15
+ import { MenuIcon } from "../icons.storyHelpers.tsx"
16
+ import { Main } from "../Main/Main.tsx"
17
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
18
+ import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
19
+ import { Shell } from "../Shell/Shell.tsx"
20
+ import { PageContent } from "../shell.storyHelpers.tsx"
21
+ import type { NavBarItem } from "./NavBar.tsx"
22
+ import { NavBar } from "./NavBar.tsx"
23
+
24
+ /**
25
+ * A stand-in for react-router's `<Link>`, and it does the one thing
26
+ * the plain `SoftRouterLink` in the other link boards does not:
27
+ * `preventDefault()`.
28
+ *
29
+ * That is not a test convenience, it is what a router does — it
30
+ * intercepts the plain left click and pushes history instead of
31
+ * letting the browser leave the page. Without it these boards
32
+ * genuinely navigate: the first run of `NavBar.test.tsx` clicked
33
+ * "Phases" and took the whole vitest iframe to `/phases`, which is
34
+ * the most direct proof available that these rows are real anchors
35
+ * and not `<button>`s.
36
+ *
37
+ * The `href` is untouched, so middle-click and ctrl-click still go
38
+ * to the platform — exactly as they do in an app.
39
+ */
40
+ const SoftRouterLink = ({
41
+ href,
42
+ onClick,
43
+ ...linkProps
44
+ }: RouterLinkProps): ReactNode => (
45
+ <a
46
+ {...linkProps}
47
+ data-router="soft"
48
+ href={href}
49
+ onClick={(event) => {
50
+ // The caller's handler first, then the interception — which
51
+ // is react-router's own order in `Link`, and it is what lets
52
+ // `NavBar` see a click that has not been cancelled yet.
53
+ onClick?.(event)
54
+
55
+ event.preventDefault()
56
+ }}
57
+ />
58
+ )
59
+
60
+ /**
61
+ * Docket's nine, in the product's own order — the nav that outgrew
62
+ * a single line and is the reason this component exists. The order
63
+ * is the pipeline work moves along, which is exactly why it may not
64
+ * be split across a bar and a menu.
65
+ */
66
+ const DESTINATIONS: NavBarItem[] = [
67
+ { href: "/triage", label: "Triage" },
68
+ { href: "/backlog", label: "Backlog" },
69
+ { href: "/phases", label: "Phases" },
70
+ { href: "/lanes", label: "Lanes" },
71
+ { href: "/board", label: "Board" },
72
+ { href: "/archives", label: "Archives" },
73
+ { href: "/tonight", label: "Tonight" },
74
+ { href: "/chats", label: "Chats" },
75
+ { href: "/settings", label: "Settings" },
76
+ ]
77
+
78
+ const FEW_DESTINATIONS = DESTINATIONS.slice(0, 3)
79
+
80
+ /**
81
+ * A fixed-width box, because the whole component is a measurement.
82
+ * A `layout: "centered"` story would size itself to its contents
83
+ * and never fold at all.
84
+ */
85
+ const Frame = ({
86
+ children,
87
+ inlineSize,
88
+ }: {
89
+ children: ReactNode
90
+ inlineSize: string
91
+ }): ReactNode => (
92
+ <div
93
+ className="flex rounded-md border border-border-subtle bg-surface-raised p-2"
94
+ style={{ inlineSize }}
95
+ >
96
+ {children}
97
+ </div>
98
+ )
99
+
100
+ const meta = {
101
+ title: "Components/Layout/NavBar",
102
+ component: NavBar,
103
+ decorators: [
104
+ (Story) => (
105
+ <RouterLinkProvider link={SoftRouterLink}>
106
+ <Story />
107
+ </RouterLinkProvider>
108
+ ),
109
+ ],
110
+ parameters: { layout: "padded" },
111
+ argTypes: {
112
+ menuPlacement: placementArgType,
113
+ size: controlSizeArgType,
114
+ },
115
+ args: {
116
+ currentHref: "/board",
117
+ items: DESTINATIONS,
118
+ label: "Main",
119
+ menuIcon: <MenuIcon />,
120
+ menuLabel: "Main menu",
121
+ size: "sm",
122
+ },
123
+ } satisfies Meta<typeof NavBar>
124
+
125
+ export default meta
126
+
127
+ type Story = StoryObj<typeof meta>
128
+
129
+ /**
130
+ * Wide enough for all nine, so there is **no menu button at all** —
131
+ * a trigger that shows at every width is not a fold, it is a nav
132
+ * you have permanently hidden.
133
+ */
134
+ export const Default: Story = {
135
+ render: (navBarProps) => (
136
+ <Frame inlineSize="52rem">
137
+ <NavBar {...navBarProps} />
138
+ </Frame>
139
+ ),
140
+ }
141
+
142
+ /**
143
+ * The two states, and there is deliberately no third.
144
+ *
145
+ * `Toolbar` drops items one at a time from the end because its items
146
+ * are **ranked**. A nav's order is the product's shape, not a
147
+ * priority list, so half of it in the bar and half behind a button
148
+ * is a rule no reader can learn.
149
+ */
150
+ export const AllVariants: Story = {
151
+ render: ({ currentHref, size }) => (
152
+ <StoryGrid columns={1}>
153
+ <StoryCell label="It fits — every destination is a link">
154
+ <Frame inlineSize="52rem">
155
+ <NavBar
156
+ currentHref={currentHref}
157
+ items={DESTINATIONS}
158
+ label="Main, wide"
159
+ menuIcon={<MenuIcon />}
160
+ menuLabel="Main menu"
161
+ size={size}
162
+ />
163
+ </Frame>
164
+ </StoryCell>
165
+
166
+ <StoryCell label="It does not fit — all nine fold together">
167
+ <Frame inlineSize="18rem">
168
+ <NavBar
169
+ currentHref={currentHref}
170
+ items={DESTINATIONS}
171
+ label="Main, narrow"
172
+ menuIcon={<MenuIcon />}
173
+ menuLabel="Main menu"
174
+ size={size}
175
+ />
176
+ </Frame>
177
+ </StoryCell>
178
+
179
+ <StoryCell
180
+ label={
181
+ 'Folded, menuAlign="end" — the trigger joins the header\'s own actions'
182
+ }
183
+ >
184
+ <Frame inlineSize="18rem">
185
+ <NavBar
186
+ currentHref={currentHref}
187
+ items={DESTINATIONS}
188
+ label="Main, trigger at the end"
189
+ menuAlign="end"
190
+ menuIcon={<MenuIcon />}
191
+ menuLabel="Main menu"
192
+ size={size}
193
+ />
194
+ </Frame>
195
+ </StoryCell>
196
+
197
+ <StoryCell label="No icon — the trigger is its own label">
198
+ <Frame inlineSize="18rem">
199
+ <NavBar
200
+ currentHref={currentHref}
201
+ items={DESTINATIONS}
202
+ label="Main, worded trigger"
203
+ menuLabel="Menu"
204
+ size={size}
205
+ />
206
+ </Frame>
207
+ </StoryCell>
208
+ </StoryGrid>
209
+ ),
210
+ }
211
+
212
+ /**
213
+ * Folded and open is the state nobody screenshots, and it is the
214
+ * one that matters: the panel is the **complete** list, in the
215
+ * product's order, with the current destination marked in it.
216
+ */
217
+ export const AllStates: Story = {
218
+ render: ({ size }) => (
219
+ <StoryGrid columns={1}>
220
+ <StoryCell label="Current destination, in the bar">
221
+ <Frame inlineSize="52rem">
222
+ <NavBar
223
+ currentHref="/board"
224
+ items={DESTINATIONS}
225
+ label="Main, current in bar"
226
+ menuIcon={<MenuIcon />}
227
+ menuLabel="Main menu"
228
+ size={size}
229
+ />
230
+ </Frame>
231
+ </StoryCell>
232
+
233
+ <StoryCell label="On a route no destination owns — nothing is current">
234
+ <Frame inlineSize="52rem">
235
+ <NavBar
236
+ currentHref="/tasks/41"
237
+ items={FEW_DESTINATIONS}
238
+ label="Main, nothing current"
239
+ menuIcon={<MenuIcon />}
240
+ menuLabel="Main menu"
241
+ size={size}
242
+ />
243
+ </Frame>
244
+ </StoryCell>
245
+
246
+ <StoryCell label="Folded, menu open — the complete list">
247
+ <Frame inlineSize="18rem">
248
+ <NavBar
249
+ currentHref="/board"
250
+ isMenuVisible
251
+ items={DESTINATIONS}
252
+ label="Main, folded and open"
253
+ menuIcon={<MenuIcon />}
254
+ menuLabel="Main menu"
255
+ size={size}
256
+ />
257
+ </Frame>
258
+ </StoryCell>
259
+ </StoryGrid>
260
+ ),
261
+ }
262
+
263
+ /**
264
+ * Measured rather than breakpointed. The same nine destinations at
265
+ * three container widths, with no pixel number anywhere in the
266
+ * component — so a longer label, a tenth destination or a denser
267
+ * theme moves the fold on its own.
268
+ */
269
+ export const Responsive: Story = {
270
+ render: ({ currentHref, size }) => (
271
+ <ContainerBoard>
272
+ {(width) => (
273
+ <Frame inlineSize="100%">
274
+ <NavBar
275
+ currentHref={currentHref}
276
+ items={DESTINATIONS}
277
+ label={`Main at ${width}`}
278
+ menuIcon={<MenuIcon />}
279
+ menuLabel="Main menu"
280
+ size={size}
281
+ />
282
+ </Frame>
283
+ )}
284
+ </ContainerBoard>
285
+ ),
286
+ }
287
+
288
+ /**
289
+ * Tab to the trigger, Enter to open, and focus moves into the
290
+ * panel; Escape closes it and hands focus back. Every row is a real
291
+ * `<a href>`, so ctrl-click and middle-click still open a tab —
292
+ * which is the entire reason this is not a `Menu`.
293
+ */
294
+ export const Interactive: Story = {
295
+ render: ({ currentHref, size }) => (
296
+ <Frame inlineSize="18rem">
297
+ <NavBar
298
+ currentHref={currentHref}
299
+ items={DESTINATIONS}
300
+ label="Main, keyboard"
301
+ menuIcon={<MenuIcon />}
302
+ menuLabel="Main menu"
303
+ size={size}
304
+ />
305
+ </Frame>
306
+ ),
307
+ }
308
+
309
+ /**
310
+ * Where it actually goes: `Shell` → `Header`, with the wordmark
311
+ * before it and the page's own action after it.
312
+ *
313
+ * The header row is a flex line, so the bar's `flex-1 min-w-0` is
314
+ * what gives it a box sized by the row rather than by its own
315
+ * links — the one arrangement rule the component cannot enforce
316
+ * from the inside.
317
+ */
318
+ const AppShellHarness = (): ReactNode => (
319
+ <Shell>
320
+ <Header
321
+ actions={
322
+ <Button intent="accent" size="sm">
323
+ Add Task
324
+ </Button>
325
+ }
326
+ heading="Docket"
327
+ >
328
+ <NavBar
329
+ currentHref="/board"
330
+ items={DESTINATIONS}
331
+ label="Main"
332
+ // The header's own action is at the far edge, so the fold
333
+ // control joins it rather than hugging the wordmark with
334
+ // the whole track empty after it.
335
+ menuAlign="end"
336
+ menuIcon={<MenuIcon />}
337
+ menuLabel="Main menu"
338
+ />
339
+ </Header>
340
+
341
+ <Main>
342
+ <PageContent />
343
+ </Main>
344
+ </Shell>
345
+ )
346
+
347
+ export const InAppShell: Story = {
348
+ parameters: { layout: "fullscreen" },
349
+ render: () => <AppShellHarness />,
350
+ }
@@ -0,0 +1,344 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import {
3
+ expect,
4
+ userEvent,
5
+ waitFor,
6
+ within,
7
+ } from "storybook/test"
8
+ import { test } from "vitest"
9
+
10
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
11
+ import { mountStory } from "../mountStory.testHelpers.ts"
12
+ import { expectAgentDrivable } from "../testing/index.ts"
13
+ import * as stories from "./NavBar.stories.tsx"
14
+
15
+ const {
16
+ AllStates,
17
+ AllVariants,
18
+ Default,
19
+ InAppShell,
20
+ Interactive,
21
+ } = composeStories(stories)
22
+
23
+ test("the nav is named, and its destinations are links", async () => {
24
+ const { canvas } = await mountStory(Default)
25
+
26
+ const nav = expectAgentDrivable(canvas, {
27
+ name: "Main",
28
+ role: "navigation",
29
+ })
30
+
31
+ const board = expectAgentDrivable(within(nav), {
32
+ name: "Board",
33
+ role: "link",
34
+ })
35
+
36
+ // A `<button onClick={navigate}>` looks identical in a screenshot
37
+ // and satisfies "the nav works". It has no `href`, so middle-click,
38
+ // ctrl-click and "copy link address" all do nothing.
39
+ await expect(board).toHaveAttribute("href", "/board")
40
+
41
+ await expectNoAxeViolations(nav)
42
+ })
43
+
44
+ /**
45
+ * The trigger exists **only when the row did not fit**. mux-magic
46
+ * shows its overflow glyph at every width, which is why its collapse
47
+ * is not a collapse at all — almost everything lives behind the
48
+ * button permanently.
49
+ */
50
+ test("a bar with room shows no menu trigger", async () => {
51
+ const { canvas } = await mountStory(Default)
52
+
53
+ const nav = expectAgentDrivable(canvas, {
54
+ name: "Main",
55
+ role: "navigation",
56
+ })
57
+
58
+ await waitFor(() => {
59
+ expect(
60
+ within(nav).queryByRole("button", {
61
+ name: "Main menu",
62
+ }),
63
+ ).toBeNull()
64
+ })
65
+
66
+ await expect(
67
+ within(nav).getAllByRole("link"),
68
+ ).toHaveLength(9)
69
+ })
70
+
71
+ /**
72
+ * All or nothing — the decision this component exists to hold.
73
+ *
74
+ * `Toolbar` would leave four of the nine in the bar at this width
75
+ * and put five behind the button, which splits the product's own
76
+ * order across two places with no rule the reader can learn.
77
+ */
78
+ test("a bar that does not fit folds every destination together", async () => {
79
+ const { canvas } = await mountStory(AllVariants)
80
+
81
+ const nav = expectAgentDrivable(canvas, {
82
+ name: "Main, narrow",
83
+ role: "navigation",
84
+ })
85
+
86
+ expectAgentDrivable(within(nav), {
87
+ name: "Main menu",
88
+ role: "button",
89
+ })
90
+
91
+ // Not "fewer links" — no links.
92
+ await waitFor(() => {
93
+ expect(within(nav).queryAllByRole("link")).toHaveLength(
94
+ 0,
95
+ )
96
+ })
97
+ })
98
+
99
+ /**
100
+ * The defect this component refuses to introduce, and the reason it
101
+ * is not a folded `Toolbar`: `MenuItem` is `{ label, onSelect }`, so
102
+ * every row in the panel would be a `<button>` — no new tab, no
103
+ * status bar, nothing to copy.
104
+ */
105
+ test("a folded destination is still a real link", async () => {
106
+ const { body } = await mountStory(AllStates)
107
+
108
+ const panel = expectAgentDrivable(body, {
109
+ name: "Main menu",
110
+ role: "dialog",
111
+ })
112
+
113
+ const archives = expectAgentDrivable(within(panel), {
114
+ name: "Archives",
115
+ role: "link",
116
+ })
117
+
118
+ await expect(archives).toHaveAttribute(
119
+ "href",
120
+ "/archives",
121
+ )
122
+
123
+ // The complete list, in the product's order — a folded nav is not
124
+ // an abridged one.
125
+ await expect(
126
+ within(panel)
127
+ .getAllByRole("link")
128
+ .map((link) => link.textContent),
129
+ ).toEqual([
130
+ "Triage",
131
+ "Backlog",
132
+ "Phases",
133
+ "Lanes",
134
+ "Board",
135
+ "Archives",
136
+ "Tonight",
137
+ "Chats",
138
+ "Settings",
139
+ ])
140
+ })
141
+
142
+ /**
143
+ * The fleet's habit is to render the nav twice and hide one copy
144
+ * with `hidden md:flex` — so every link is in the DOM at every
145
+ * width, and an agent driving the page finds two of each with no way
146
+ * to tell which one a human can see.
147
+ */
148
+ test("a destination is mounted exactly once", async () => {
149
+ const { body, canvas } = await mountStory(AllStates)
150
+
151
+ const nav = expectAgentDrivable(canvas, {
152
+ name: "Main, folded and open",
153
+ role: "navigation",
154
+ })
155
+
156
+ await expect(
157
+ within(nav).queryAllByRole("link"),
158
+ ).toHaveLength(0)
159
+
160
+ await waitFor(() => {
161
+ expect(
162
+ within(
163
+ body.getByRole("dialog", { name: "Main menu" }),
164
+ ).getAllByRole("link", { name: "Chats" }),
165
+ ).toHaveLength(1)
166
+ })
167
+ })
168
+
169
+ /**
170
+ * A highlight is invisible to a screen reader and to anyone who
171
+ * cannot separate the two surfaces, so the current destination says
172
+ * so in the platform's own words — and exactly one does.
173
+ */
174
+ test("the current destination is marked, and only it", async () => {
175
+ const { canvas } = await mountStory(AllStates)
176
+
177
+ const nav = expectAgentDrivable(canvas, {
178
+ name: "Main, current in bar",
179
+ role: "navigation",
180
+ })
181
+
182
+ await expect(
183
+ within(nav)
184
+ .getAllByRole("link")
185
+ .filter(
186
+ (link) =>
187
+ link.getAttribute("aria-current") === "page",
188
+ )
189
+ .map((link) => link.textContent),
190
+ ).toEqual(["Board"])
191
+ })
192
+
193
+ /**
194
+ * `/tasks/41` belongs to no destination, and the honest answer is
195
+ * that none is current. react-router's `<NavLink to="/">` would mark
196
+ * the root here unless the caller remembered `end`.
197
+ */
198
+ test("a route no destination owns marks nothing", async () => {
199
+ const { canvas } = await mountStory(AllStates)
200
+
201
+ const nav = expectAgentDrivable(canvas, {
202
+ name: "Main, nothing current",
203
+ role: "navigation",
204
+ })
205
+
206
+ await expect(
207
+ within(nav)
208
+ .getAllByRole("link")
209
+ .filter((link) => link.hasAttribute("aria-current")),
210
+ ).toHaveLength(0)
211
+ })
212
+
213
+ /**
214
+ * A fold control belongs at an edge. `menuAlign="end"` puts it
215
+ * beside the header's own actions instead of hugging the wordmark
216
+ * with the whole track empty after it — and it reaches only the
217
+ * folded row, so the links' own position is not a prop.
218
+ */
219
+ test("the folded trigger can sit at the end of the track", async () => {
220
+ const { canvas } = await mountStory(AllVariants)
221
+
222
+ const aligned = expectAgentDrivable(canvas, {
223
+ name: "Main, trigger at the end",
224
+ role: "navigation",
225
+ })
226
+
227
+ const start = expectAgentDrivable(canvas, {
228
+ name: "Main, narrow",
229
+ role: "navigation",
230
+ })
231
+
232
+ await waitFor(() => {
233
+ expect(
234
+ within(aligned)
235
+ .getByRole("button", { name: "Main menu" })
236
+ .getBoundingClientRect().left,
237
+ ).toBeGreaterThan(
238
+ within(start)
239
+ .getByRole("button", { name: "Main menu" })
240
+ .getBoundingClientRect().left,
241
+ )
242
+ })
243
+ })
244
+
245
+ /**
246
+ * The trigger advertises what it opens, and gets focus back when it
247
+ * closes — both from `useRole` and `FloatingFocusManager` inside
248
+ * `Popover`, so neither can be forgotten here.
249
+ */
250
+ test("the trigger advertises the panel it opens", async () => {
251
+ const { body, canvas } = await mountStory(Interactive)
252
+
253
+ const trigger = expectAgentDrivable(canvas, {
254
+ name: "Main menu",
255
+ role: "button",
256
+ })
257
+
258
+ await expect(trigger).toHaveAttribute(
259
+ "aria-expanded",
260
+ "false",
261
+ )
262
+
263
+ await expect(trigger).toHaveAttribute(
264
+ "aria-haspopup",
265
+ "dialog",
266
+ )
267
+
268
+ await userEvent.click(trigger)
269
+
270
+ await expect(trigger).toHaveAttribute(
271
+ "aria-expanded",
272
+ "true",
273
+ )
274
+
275
+ const panel = expectAgentDrivable(body, {
276
+ name: "Main menu",
277
+ role: "dialog",
278
+ })
279
+
280
+ await waitFor(() => {
281
+ expect(panel).toContainElement(
282
+ document.activeElement as HTMLElement,
283
+ )
284
+ })
285
+
286
+ await userEvent.keyboard("{Escape}")
287
+
288
+ await waitFor(() => {
289
+ expect(trigger).toHaveFocus()
290
+ })
291
+ })
292
+
293
+ /**
294
+ * Choosing a destination closes the menu behind you — the one
295
+ * interaction people read as "it didn't work" when it is missing.
296
+ */
297
+ test("following a link in the panel closes it", async () => {
298
+ const { body, canvas } = await mountStory(Interactive)
299
+
300
+ const trigger = expectAgentDrivable(canvas, {
301
+ name: "Main menu",
302
+ role: "button",
303
+ })
304
+
305
+ await userEvent.click(trigger)
306
+
307
+ const panel = expectAgentDrivable(body, {
308
+ name: "Main menu",
309
+ role: "dialog",
310
+ })
311
+
312
+ await userEvent.click(
313
+ within(panel).getByRole("link", { name: "Phases" }),
314
+ )
315
+
316
+ await waitFor(() => {
317
+ expect(
318
+ body.queryByRole("dialog", { name: "Main menu" }),
319
+ ).toBeNull()
320
+ })
321
+ })
322
+
323
+ test("the folded nav audits clean while open", async () => {
324
+ const { canvasElement } = await mountStory(AllStates)
325
+
326
+ await expectNoAxeViolations(canvasElement)
327
+
328
+ // The panel is portalled out of the canvas, so the resting audit
329
+ // above cannot see it — an overflow audited only while shut is an
330
+ // overflow nobody audited.
331
+ await expectNoAxeViolations(document.body)
332
+ })
333
+
334
+ /**
335
+ * Where it actually goes. `Header`'s row is what sizes the bar; the
336
+ * bar measures that box to decide what to draw in it.
337
+ */
338
+ test("the shell mounts exactly one nav", async () => {
339
+ const { canvas } = await mountStory(InAppShell)
340
+
341
+ await expect(
342
+ canvas.getAllByRole("navigation", { name: "Main" }),
343
+ ).toHaveLength(1)
344
+ })