@charcuterie/ui 3.23.0 → 3.24.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 (62) hide show
  1. package/dist/Board/Board.d.ts +6 -3
  2. package/dist/Board/Board.d.ts.map +1 -1
  3. package/dist/Board/Board.js +19 -1
  4. package/dist/Board/Board.js.map +1 -1
  5. package/dist/Board/BoardCard.d.ts +42 -7
  6. package/dist/Board/BoardCard.d.ts.map +1 -1
  7. package/dist/Board/BoardCard.js +54 -8
  8. package/dist/Board/BoardCard.js.map +1 -1
  9. package/dist/Nav/Nav.d.ts +167 -0
  10. package/dist/Nav/Nav.d.ts.map +1 -0
  11. package/dist/Nav/Nav.js +165 -0
  12. package/dist/Nav/Nav.js.map +1 -0
  13. package/dist/Nav/NavLink.d.ts +43 -0
  14. package/dist/Nav/NavLink.d.ts.map +1 -0
  15. package/dist/Nav/NavLink.js +111 -0
  16. package/dist/Nav/NavLink.js.map +1 -0
  17. package/dist/Nav/navItems.d.ts +67 -0
  18. package/dist/Nav/navItems.d.ts.map +1 -0
  19. package/dist/Nav/navItems.js +104 -0
  20. package/dist/Nav/navItems.js.map +1 -0
  21. package/dist/Nav/useNavLayout.d.ts +86 -0
  22. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  23. package/dist/Nav/useNavLayout.js +114 -0
  24. package/dist/Nav/useNavLayout.js.map +1 -0
  25. package/dist/NavBar/NavBar.d.ts +37 -111
  26. package/dist/NavBar/NavBar.d.ts.map +1 -1
  27. package/dist/NavBar/NavBar.js +30 -110
  28. package/dist/NavBar/NavBar.js.map +1 -1
  29. package/dist/index.d.ts +6 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Board/Board.mdx +25 -2
  35. package/src/Board/Board.stories.tsx +52 -1
  36. package/src/Board/Board.test.tsx +82 -0
  37. package/src/Board/Board.tsx +24 -4
  38. package/src/Board/BoardCard.tsx +103 -21
  39. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  40. package/src/Nav/Nav.mdx +169 -0
  41. package/src/Nav/Nav.stories.tsx +402 -0
  42. package/src/Nav/Nav.test.tsx +338 -0
  43. package/src/Nav/Nav.tsx +406 -0
  44. package/src/Nav/NavLink.tsx +220 -0
  45. package/src/Nav/navItems.test.ts +163 -0
  46. package/src/Nav/navItems.ts +175 -0
  47. package/src/Nav/useNavLayout.ts +197 -0
  48. package/src/NavBar/NavBar.mdx +26 -0
  49. package/src/NavBar/NavBar.tsx +54 -240
  50. package/src/index.ts +20 -1
  51. package/src/sourceRules.test.ts +13 -1
  52. package/dist/NavBar/NavBarLink.d.ts +0 -43
  53. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  54. package/dist/NavBar/NavBarLink.js +0 -67
  55. package/dist/NavBar/NavBarLink.js.map +0 -1
  56. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  57. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  58. package/dist/NavBar/navBarCurrent.js +0 -48
  59. package/dist/NavBar/navBarCurrent.js.map +0 -1
  60. package/src/NavBar/NavBarLink.tsx +0 -139
  61. package/src/NavBar/navBarCurrent.test.ts +0 -81
  62. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -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
+ }