@charcuterie/ui 3.23.0 → 3.25.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.
Files changed (68) hide show
  1. package/README.md +18 -0
  2. package/dist/Board/Board.d.ts +6 -3
  3. package/dist/Board/Board.d.ts.map +1 -1
  4. package/dist/Board/Board.js +19 -1
  5. package/dist/Board/Board.js.map +1 -1
  6. package/dist/Board/BoardCard.d.ts +42 -7
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +54 -8
  9. package/dist/Board/BoardCard.js.map +1 -1
  10. package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts +31 -0
  11. package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts.map +1 -0
  12. package/dist/DeploymentUpdate/useDeploymentUpdate.js +94 -0
  13. package/dist/DeploymentUpdate/useDeploymentUpdate.js.map +1 -0
  14. package/dist/Nav/Nav.d.ts +167 -0
  15. package/dist/Nav/Nav.d.ts.map +1 -0
  16. package/dist/Nav/Nav.js +165 -0
  17. package/dist/Nav/Nav.js.map +1 -0
  18. package/dist/Nav/NavLink.d.ts +43 -0
  19. package/dist/Nav/NavLink.d.ts.map +1 -0
  20. package/dist/Nav/NavLink.js +111 -0
  21. package/dist/Nav/NavLink.js.map +1 -0
  22. package/dist/Nav/navItems.d.ts +67 -0
  23. package/dist/Nav/navItems.d.ts.map +1 -0
  24. package/dist/Nav/navItems.js +104 -0
  25. package/dist/Nav/navItems.js.map +1 -0
  26. package/dist/Nav/useNavLayout.d.ts +86 -0
  27. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  28. package/dist/Nav/useNavLayout.js +114 -0
  29. package/dist/Nav/useNavLayout.js.map +1 -0
  30. package/dist/NavBar/NavBar.d.ts +37 -111
  31. package/dist/NavBar/NavBar.d.ts.map +1 -1
  32. package/dist/NavBar/NavBar.js +30 -110
  33. package/dist/NavBar/NavBar.js.map +1 -1
  34. package/dist/index.d.ts +8 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -1
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/Board/Board.mdx +25 -2
  40. package/src/Board/Board.stories.tsx +52 -1
  41. package/src/Board/Board.test.tsx +82 -0
  42. package/src/Board/Board.tsx +24 -4
  43. package/src/Board/BoardCard.tsx +103 -21
  44. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  45. package/src/DeploymentUpdate/useDeploymentUpdate.ts +143 -0
  46. package/src/Nav/Nav.mdx +169 -0
  47. package/src/Nav/Nav.stories.tsx +402 -0
  48. package/src/Nav/Nav.test.tsx +338 -0
  49. package/src/Nav/Nav.tsx +406 -0
  50. package/src/Nav/NavLink.tsx +220 -0
  51. package/src/Nav/navItems.test.ts +163 -0
  52. package/src/Nav/navItems.ts +175 -0
  53. package/src/Nav/useNavLayout.ts +197 -0
  54. package/src/NavBar/NavBar.mdx +26 -0
  55. package/src/NavBar/NavBar.tsx +54 -240
  56. package/src/index.ts +29 -1
  57. package/src/sourceRules.test.ts +13 -1
  58. package/dist/NavBar/NavBarLink.d.ts +0 -43
  59. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  60. package/dist/NavBar/NavBarLink.js +0 -67
  61. package/dist/NavBar/NavBarLink.js.map +0 -1
  62. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  63. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  64. package/dist/NavBar/navBarCurrent.js +0 -48
  65. package/dist/NavBar/navBarCurrent.js.map +0 -1
  66. package/src/NavBar/NavBarLink.tsx +0 -139
  67. package/src/NavBar/navBarCurrent.test.ts +0 -81
  68. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -0,0 +1,169 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as NavStories from "./Nav.stories";
4
+
5
+ <Meta of={NavStories} />
6
+
7
+ # Nav
8
+
9
+ The app's destinations. A header row, a side rail, a collapsed rail, a strip along the foot
10
+ of a phone, or a panel behind one control — the same list, drawn where it fits.
11
+
12
+ > **This replaces [`NavBar`](?path=/docs/components-layout-navbar--docs)**, which shipped the
13
+ > header row on its own and is deprecated as of this release. `bar` is `Nav`'s default
14
+ > layout and behaves identically, down to the measured whole-row fold — see *`bar` folds
15
+ > whole* below.
16
+
17
+ <Canvas of={NavStories.Default} />
18
+
19
+ ## Props
20
+
21
+ <Controls />
22
+
23
+ ## What was actually missing
24
+
25
+ `Shell` / `Header` / `Rail` / `Main` shipped the page frame on 2026-08-10 and left **the
26
+ rail empty**. Twelve UI repos, and exactly two have a `<nav>` at all: Docket hand-rolled a
27
+ row of `NavLink`s with the collapse rule in its own stylesheet, Folio hand-rolled another,
28
+ and the other ten have no destinations anywhere.
29
+
30
+ The two that exist already disagree. Docket *wraps* below its breakpoint, so its header
31
+ stands three lines tall on a phone — which is not a decision anybody made, it is what
32
+ `flex-wrap` does when nobody writes the other rule.
33
+
34
+ ## Every item is a real link
35
+
36
+ <Canvas of={NavStories.AllVariants} />
37
+
38
+ `<a href>`, through the injected `RouterLinkProvider` — the same seam `TextLink` and
39
+ `ButtonLink` take, so an app injects its router once and every destination in the library
40
+ routes.
41
+
42
+ Not a `<button onClick={navigate}>`. That is what mux-magic and QueuePilot do today, it is
43
+ **identical in a screenshot**, and it loses middle-click, ctrl-click, "open in a new tab",
44
+ the status bar preview, and "copy link address". See
45
+ [buttons are actions, links are navigation](?path=/docs/guides-routing--docs).
46
+
47
+ An `isExternal` item is the exception the seam already knows: a plain anchor,
48
+ `target="_blank"`, `rel="noopener noreferrer"`. Pushing `https://…` onto a router's history
49
+ navigates the SPA to a route that does not exist.
50
+
51
+ ## `icon`, and `NavRailItem`
52
+
53
+ `NavItem` keeps `icon` optional, because a `bar` never takes the label away — Docket's nine
54
+ destinations are words, and inventing nine glyphs to satisfy a type would be worse than the
55
+ problem.
56
+
57
+ A rail is the other case. `railIcons` is one `--control-height-lg` square and shows nothing
58
+ else, so an item with no glyph is a blank square that still navigates, and there is no visual
59
+ defect to notice because the row reads as padding. **Annotate a rail's array as
60
+ `NavRailItem[]`** — the same shape with `icon` made required — and the type checker catches
61
+ it before somebody drags a window narrow.
62
+
63
+ `items` is deliberately **not** discriminated on `layout`. The main call site passes
64
+ `layout={navLayout.layout}` straight from `useNavLayout`, which is a variable and not a
65
+ literal, so a union that split the item type by layout would force a `switch` at the one
66
+ place this component exists to make short.
67
+
68
+ The library still ships no icons — lucide (ISC) is the fleet recommendation, and a glyph in
69
+ a default renders as nothing where the font lacks it.
70
+
71
+ ## `bar` folds whole, and it is measured
72
+
73
+ There is no `collapseAt` and no media query on this axis. The bar reads the width its links
74
+ actually have and folds when their sum stops fitting, so a longer label, a tenth destination,
75
+ a `data-density="kiosk"` or a reader at 175% browser zoom each move the fold on their own.
76
+ mux-magic collapses at a hardcoded `480px` and plex-channels at `760px`, and neither number
77
+ has a reason.
78
+
79
+ It folds **whole** rather than progressively, unlike `Toolbar`. `Toolbar`'s items are ranked;
80
+ a nav's order is the shape of the product — Docket's reads as the pipeline, Triage → Backlog
81
+ → Phases → Board → Archives — and half of that behind a button is a rule no reader can learn.
82
+
83
+ ⚠️ **A `bar` must not be sized by its own contents.** It measures its own box to decide what
84
+ to draw in it, so a container that shrink-wraps makes the answer its own input: put it in a
85
+ `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row.
86
+
87
+ ## The current destination is announced
88
+
89
+ <Canvas of={NavStories.AllStates} />
90
+
91
+ `aria-current="page"`, and a background. One without the other is WCAG 1.4.1, and a
92
+ `font-weight` swap alone is exactly that failure.
93
+
94
+ Matching is two rules:
95
+
96
+ 1. An exact path wins — `/board` on `/board`.
97
+ 2. Otherwise the **longest path-segment prefix** wins. `/board` matches `/board/inProgress`
98
+ and does **not** match `/boardgames`, because `games` is not a new segment. Longest, so
99
+ an app with `/settings` and `/settings/labels` marks the deeper one.
100
+
101
+ The root is exempt from rule 2: `/` prefixes every path in the app, so a permissive root
102
+ would mark Home current on every screen and the state would carry no information.
103
+
104
+ Query and fragment are stripped first. A destination and a filtered view of it are the same
105
+ place — `/board?lane=todo` is still Board, which is the case that breaks first in a real app.
106
+
107
+ **And exactly one item is current.** `getIsCurrentHref` answers for a single link, so a nav
108
+ holding both `/settings` and `/settings/labels` gets `true` twice while the reader is on the
109
+ deeper one — which is what `NavBar` shipped. `resolveActiveKey` is the whole-list answer and
110
+ takes the **deepest** match, by path rather than by array position, so the product's own
111
+ order never decides which destination lights up.
112
+
113
+ Both are exported, so an app can assert its own table without mounting anything.
114
+
115
+ ## Three widths, one rule — `useNavLayout`
116
+
117
+ <Canvas of={NavStories.Responsive} />
118
+
119
+ ```tsx
120
+ const nav = useNavLayout({ storageKey: "queuepilot-nav" })
121
+
122
+ // nav.layout: "rail" | "railIcons" | "menu"
123
+ // nav.isCollapsed, nav.mode, nav.setMode, nav.toggle
124
+ ```
125
+
126
+ | Width | `layout` | What the app renders |
127
+ | --- | --- | --- |
128
+ | `>= 64rem` | `rail` | a `Rail` holding a `Nav` |
129
+ | `>= 48rem` | `railIcons` | the same `Rail`, narrower |
130
+ | below | `menu` | a trigger in the `Header` |
131
+
132
+ `toggle()` overrides the width and the choice is remembered under `storageKey`. The
133
+ override does **not** reach the narrow state — `expanded` on a 390px screen is a 212px rail
134
+ beside 178px of content — and the remembered value is kept rather than cleared, so rotating
135
+ a tablet back to landscape restores the rail the reader asked for.
136
+
137
+ **Why the app still places it.** The placement genuinely differs and the rule does not. The
138
+ `menu` state puts a control in the header, which is a different corner of `Shell`'s grid
139
+ from a side rail, and no amount of restyling moves an element across a grid. So the library
140
+ owns *which* state is right, at what width, with what override, remembered where; the app
141
+ owns two `if`s.
142
+
143
+ ## The narrow panel is a `Popover`, not a `Menu`
144
+
145
+ <Canvas of={NavStories.Interactive} />
146
+
147
+ `Menu`'s items are `onSelect` callbacks on `<button>`s. That is right for what a menu
148
+ usually holds — undo, redo, a scheme cycle — and wrong for a destination, for every reason
149
+ in *Every item is a real link* above.
150
+
151
+ So `layout="menu"` is a `Popover`: anchored, dismissable, portalled to the body, and
152
+ `modal={false}` so the caret is not trapped. It is a disclosure, not a dialog somebody has
153
+ to answer. Escape and an outside press both close it.
154
+
155
+ Give the trigger a real name. A bare hamburger is a glyph, and a glyph is not a name — the
156
+ reason `IconButton` requires `label`.
157
+
158
+ ## When to use which layout
159
+
160
+ | Layout | For |
161
+ | --- | --- |
162
+ | `bar` | three to six destinations, and a grid that wants every pixel — ai-usage, folio, mux-magic, portly-controllers. Folds itself; needs no `useNavLayout`. |
163
+ | `rail` / `railIcons` | seven or more, or a pipeline you read down — Docket, mail-sifter, QueuePilot |
164
+ | `bottom` | three or four you switch between constantly, in the Narrow View only |
165
+ | `menu` | the Narrow View of a `rail` app |
166
+
167
+ `bottom` is deliberately not the default narrow answer. It costs a permanent strip of the
168
+ screen, and it earns that only when the destinations are few and the switching is constant.
169
+ An app you settle into for an evening wants the panel.
@@ -0,0 +1,402 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import {
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import {
11
+ BulletedListIcon,
12
+ ExternalIcon,
13
+ InboxIcon,
14
+ MenuIcon,
15
+ MoonIcon,
16
+ PlayIcon,
17
+ SettingsIcon,
18
+ } from "../icons.storyHelpers.tsx"
19
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
20
+ import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
21
+ import { Nav } from "./Nav.tsx"
22
+ import type { NavRailItem } from "./navItems.ts"
23
+
24
+ /**
25
+ * A stand-in for react-router's `<Link>`, and it does the one thing
26
+ * a plain anchor must not do here: `preventDefault()`.
27
+ *
28
+ * That is not a test convenience, it is what a router does — it
29
+ * intercepts the plain left click and pushes history instead of
30
+ * letting the browser leave the page. Without it these boards
31
+ * genuinely navigate away.
32
+ *
33
+ * The `href` is untouched, so middle-click and ctrl-click still go
34
+ * to the platform, exactly as they do in an app.
35
+ */
36
+ const SoftRouterLink = ({
37
+ href,
38
+ onClick,
39
+ ...linkProps
40
+ }: RouterLinkProps): ReactNode => (
41
+ <a
42
+ {...linkProps}
43
+ data-router="soft"
44
+ href={href}
45
+ onClick={(event) => {
46
+ // The caller's handler first, then the interception — which is
47
+ // react-router's own order in `Link`, and it is what lets a
48
+ // folded panel see a click that has not been cancelled yet.
49
+ onClick?.(event)
50
+
51
+ event.preventDefault()
52
+ }}
53
+ />
54
+ )
55
+
56
+ /**
57
+ * A fixed-width box, because `bar` is a measurement. A story that
58
+ * sizes itself to its contents never folds at all.
59
+ */
60
+ const Frame = ({
61
+ children,
62
+ inlineSize,
63
+ }: {
64
+ children: ReactNode
65
+ inlineSize: string
66
+ }): ReactNode => (
67
+ <div
68
+ className="flex rounded-md border border-border-subtle bg-surface-raised p-2"
69
+ style={{ inlineSize }}
70
+ >
71
+ {children}
72
+ </div>
73
+ )
74
+
75
+ /**
76
+ * **Invented, all of it.** The app this was built alongside is a
77
+ * household's media tool and a published library must not carry its
78
+ * shelves, so these are a fictional radio station's screens. The
79
+ * shape is what matters and the shape is the same: five or six
80
+ * destinations, two of them configuration, one of them leaving the
81
+ * app for a source it does not own.
82
+ */
83
+ const STATION_ITEMS: NavRailItem[] = [
84
+ {
85
+ href: "/",
86
+ icon: <PlayIcon />,
87
+ key: "now",
88
+ label: "On air",
89
+ },
90
+ {
91
+ href: "/tonight",
92
+ icon: <MoonIcon />,
93
+ key: "tonight",
94
+ label: "Tonight",
95
+ },
96
+ {
97
+ href: "/library",
98
+ icon: <BulletedListIcon />,
99
+ key: "library",
100
+ label: "Library",
101
+ },
102
+ {
103
+ href: "/incoming",
104
+ icon: <InboxIcon />,
105
+ key: "incoming",
106
+ label: "Incoming",
107
+ },
108
+ {
109
+ href: "/settings",
110
+ icon: <SettingsIcon />,
111
+ key: "settings",
112
+ label: "Settings",
113
+ },
114
+ ]
115
+
116
+ /**
117
+ * The same list with a destination that leaves the app, and one long
118
+ * enough to prove the truncation rule. A rail that only looks right
119
+ * with short labels has not been tested.
120
+ */
121
+ const STATION_ITEMS_WITH_EXTERNAL: NavRailItem[] = [
122
+ ...STATION_ITEMS,
123
+ {
124
+ href: "https://example.com/catalogue",
125
+ icon: <ExternalIcon />,
126
+ isExternal: true,
127
+ key: "catalogue",
128
+ label: "Publisher catalogue — every pressing",
129
+ },
130
+ ]
131
+
132
+ const meta = {
133
+ component: Nav,
134
+ decorators: [
135
+ (Story) => (
136
+ <RouterLinkProvider link={SoftRouterLink}>
137
+ <Story />
138
+ </RouterLinkProvider>
139
+ ),
140
+ ],
141
+ parameters: { layout: "padded" },
142
+ title: "Components/Layout/Nav",
143
+ } satisfies Meta<typeof Nav>
144
+
145
+ export default meta
146
+
147
+ // `typeof Nav`, not `typeof meta`: `NavProps` is a union, and
148
+ // Storybook resolves a `satisfies Meta<>` object with decorators
149
+ // down to `never` args when the component's props do not intersect.
150
+ type Story = StoryObj<typeof Nav>
151
+
152
+ /**
153
+ * A header row, which is what an app with a handful of destinations
154
+ * wants: ai-usage, folio, mux-magic, portly-controllers.
155
+ */
156
+ export const Default: Story = {
157
+ args: {
158
+ activeHref: "/library",
159
+ items: STATION_ITEMS,
160
+ layout: "bar",
161
+ menuIcon: <MenuIcon />,
162
+ menuLabel: "Main menu",
163
+ },
164
+ render: (navProps) => (
165
+ // Wide enough for all five, so there is **no menu button at
166
+ // all** — a trigger that shows at every width is not a fold, it
167
+ // is a nav somebody has permanently hidden.
168
+ <Frame inlineSize="52rem">
169
+ <Nav {...navProps} />
170
+ </Frame>
171
+ ),
172
+ }
173
+
174
+ /**
175
+ * The four placed layouts side by side. `menu` is not here — it is a
176
+ * panel that needs a trigger, and it has `Interactive` to itself.
177
+ */
178
+ export const AllVariants: Story = {
179
+ args: { items: STATION_ITEMS },
180
+ render: (args) => (
181
+ <div className="flex flex-col gap-8">
182
+ <StorySection title="bar — a header row, with room">
183
+ <Frame inlineSize="52rem">
184
+ <Nav
185
+ {...args}
186
+ activeHref="/library"
187
+ layout="bar"
188
+ />
189
+ </Frame>
190
+ </StorySection>
191
+
192
+ {/*
193
+ The same bar and the same five destinations, in a box that
194
+ cannot hold them. It folds WHOLE: `Toolbar` would leave two
195
+ in the row and put three behind the button, which splits the
196
+ product's own order across two places with no rule the
197
+ reader can learn.
198
+ */}
199
+ <StorySection title="bar — the same five, folded">
200
+ <Frame inlineSize="14rem">
201
+ <Nav
202
+ {...args}
203
+ activeHref="/library"
204
+ label="Main, folded"
205
+ layout="bar"
206
+ menuIcon={<MenuIcon />}
207
+ menuLabel="Main menu"
208
+ />
209
+ </Frame>
210
+ </StorySection>
211
+
212
+ {/*
213
+ The rail layouts take `NavRailItem[]`, so they are given the
214
+ fixture directly rather than through `args` — spreading the
215
+ story's args would widen `icon` back to optional and the
216
+ three cells below would stop type-checking, which is the
217
+ rule working rather than failing.
218
+ */}
219
+ <StoryGrid columns={3}>
220
+ <StoryCell align="stretch" label="rail">
221
+ <Nav
222
+ activeHref="/library"
223
+ items={STATION_ITEMS}
224
+ label="Rail with labels"
225
+ layout="rail"
226
+ />
227
+ </StoryCell>
228
+
229
+ <StoryCell align="stretch" label="railIcons">
230
+ <Nav
231
+ activeHref="/library"
232
+ items={STATION_ITEMS}
233
+ label="Rail without labels"
234
+ layout="railIcons"
235
+ />
236
+ </StoryCell>
237
+
238
+ <StoryCell align="stretch" label="bottom">
239
+ <Nav
240
+ activeHref="/library"
241
+ items={STATION_ITEMS}
242
+ label="Bottom bar"
243
+ layout="bottom"
244
+ />
245
+ </StoryCell>
246
+ </StoryGrid>
247
+ </div>
248
+ ),
249
+ }
250
+
251
+ /**
252
+ * Every state one item can be in, and the two that are easy to get
253
+ * wrong are the last two.
254
+ *
255
+ * A **long label** must truncate rather than widen the column: a
256
+ * flex item's automatic minimum is its content's min-content width,
257
+ * so an untruncated destination name is the rail's floor.
258
+ *
259
+ * An **external** destination is never current, even while the app
260
+ * sits at a path that looks like it. It is somebody else's origin.
261
+ */
262
+ export const AllStates: Story = {
263
+ args: { items: STATION_ITEMS },
264
+ render: () => (
265
+ <StoryGrid columns={2}>
266
+ <StoryCell align="stretch" label="nothing current">
267
+ <Nav
268
+ items={STATION_ITEMS}
269
+ label="Nothing current"
270
+ layout="rail"
271
+ />
272
+ </StoryCell>
273
+
274
+ <StoryCell align="stretch" label="current, exact">
275
+ <Nav
276
+ activeHref="/library"
277
+ items={STATION_ITEMS}
278
+ label="Current exact"
279
+ layout="rail"
280
+ />
281
+ </StoryCell>
282
+
283
+ <StoryCell
284
+ align="stretch"
285
+ label="current, one segment deeper"
286
+ >
287
+ <Nav
288
+ activeHref="/library/a-flock-of-seagulls?sort=year"
289
+ items={STATION_ITEMS}
290
+ label="Current deeper"
291
+ layout="rail"
292
+ />
293
+ </StoryCell>
294
+
295
+ <StoryCell
296
+ align="stretch"
297
+ label="a long label, and one that leaves"
298
+ >
299
+ <Nav
300
+ activeHref="/library"
301
+ items={STATION_ITEMS_WITH_EXTERNAL}
302
+ label="Long and external"
303
+ layout="rail"
304
+ />
305
+ </StoryCell>
306
+ </StoryGrid>
307
+ ),
308
+ }
309
+
310
+ /**
311
+ * The three widths a rail-shaped nav takes, drawn at the sizes that
312
+ * produce them rather than by resizing the window.
313
+ *
314
+ * `useNavLayout` is what picks between them in an app. Here they are
315
+ * placed by hand, because the point of the board is to show all
316
+ * three at once — which a hook keyed to the viewport cannot do.
317
+ */
318
+ export const Responsive: Story = {
319
+ args: { items: STATION_ITEMS },
320
+ render: () => (
321
+ <div className="flex flex-wrap items-start gap-6">
322
+ {[
323
+ {
324
+ inlineSize: "13rem",
325
+ label: "≥ 64rem — rail",
326
+ layout: "rail" as const,
327
+ },
328
+ {
329
+ inlineSize: "4rem",
330
+ label: "≥ 48rem — railIcons",
331
+ layout: "railIcons" as const,
332
+ },
333
+ {
334
+ inlineSize: "22rem",
335
+ label: "< 48rem — bottom (or menu)",
336
+ layout: "bottom" as const,
337
+ },
338
+ ].map((panel) => (
339
+ <div
340
+ className="flex flex-col gap-2"
341
+ key={panel.label}
342
+ style={{ inlineSize: panel.inlineSize }}
343
+ >
344
+ <span className="font-mono text-content-muted text-xs">
345
+ {panel.label}
346
+ </span>
347
+
348
+ <div className="rounded-md border border-border-subtle bg-surface-raised p-2">
349
+ <Nav
350
+ activeHref="/library"
351
+ items={STATION_ITEMS}
352
+ label={panel.label}
353
+ layout={panel.layout}
354
+ />
355
+ </div>
356
+ </div>
357
+ ))}
358
+ </div>
359
+ ),
360
+ }
361
+
362
+ /**
363
+ * The Narrow View, and the whole keyboard path: Tab to the trigger,
364
+ * Enter to open, Tab through real links, Escape to close.
365
+ *
366
+ * The trigger says **"Menu"** rather than carrying a bare hamburger,
367
+ * for the reason `IconButton` exists — a glyph is not a name.
368
+ */
369
+ export const Interactive: Story = {
370
+ args: { items: STATION_ITEMS },
371
+ render: () => {
372
+ const NavMenuStory = () => {
373
+ const [isVisible, setIsVisible] = useState(false)
374
+
375
+ return (
376
+ <Nav
377
+ activeHref="/library"
378
+ isVisible={isVisible}
379
+ items={STATION_ITEMS_WITH_EXTERNAL}
380
+ label="Main"
381
+ layout="menu"
382
+ onDismiss={() => {
383
+ setIsVisible(false)
384
+ }}
385
+ trigger={
386
+ <Button
387
+ appearance="outline"
388
+ intent="neutral"
389
+ onClick={() => {
390
+ setIsVisible((isOpen) => !isOpen)
391
+ }}
392
+ >
393
+ Menu
394
+ </Button>
395
+ }
396
+ />
397
+ )
398
+ }
399
+
400
+ return <NavMenuStory />
401
+ },
402
+ }