@charcuterie/ui 3.21.0 → 3.23.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 (49) hide show
  1. package/README.md +22 -0
  2. package/dist/Board/useBoardDrag.d.ts +15 -1
  3. package/dist/Board/useBoardDrag.d.ts.map +1 -1
  4. package/dist/Board/useBoardDrag.js +3 -3
  5. package/dist/Board/useBoardDrag.js.map +1 -1
  6. package/dist/DropRail/DropRail.d.ts +109 -0
  7. package/dist/DropRail/DropRail.d.ts.map +1 -0
  8. package/dist/DropRail/DropRail.js +277 -0
  9. package/dist/DropRail/DropRail.js.map +1 -0
  10. package/dist/NavBar/NavBar.d.ts +130 -0
  11. package/dist/NavBar/NavBar.d.ts.map +1 -0
  12. package/dist/NavBar/NavBar.js +114 -0
  13. package/dist/NavBar/NavBar.js.map +1 -0
  14. package/dist/NavBar/NavBarLink.d.ts +43 -0
  15. package/dist/NavBar/NavBarLink.d.ts.map +1 -0
  16. package/dist/NavBar/NavBarLink.js +67 -0
  17. package/dist/NavBar/NavBarLink.js.map +1 -0
  18. package/dist/NavBar/navBarCurrent.d.ts +37 -0
  19. package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
  20. package/dist/NavBar/navBarCurrent.js +48 -0
  21. package/dist/NavBar/navBarCurrent.js.map +1 -0
  22. package/dist/ReorderList/ReorderList.d.ts +131 -0
  23. package/dist/ReorderList/ReorderList.d.ts.map +1 -0
  24. package/dist/ReorderList/ReorderList.js +154 -0
  25. package/dist/ReorderList/ReorderList.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/Board/useBoardDrag.ts +17 -2
  32. package/src/DropRail/DropRail.mdx +90 -0
  33. package/src/DropRail/DropRail.stories.tsx +328 -0
  34. package/src/DropRail/DropRail.test.tsx +154 -0
  35. package/src/DropRail/DropRail.tsx +496 -0
  36. package/src/NavBar/NavBar.mdx +157 -0
  37. package/src/NavBar/NavBar.stories.tsx +350 -0
  38. package/src/NavBar/NavBar.test.tsx +344 -0
  39. package/src/NavBar/NavBar.tsx +271 -0
  40. package/src/NavBar/NavBarLink.tsx +139 -0
  41. package/src/NavBar/navBarCurrent.test.ts +81 -0
  42. package/src/NavBar/navBarCurrent.ts +64 -0
  43. package/src/ReorderList/ReorderList.mdx +83 -0
  44. package/src/ReorderList/ReorderList.stories.tsx +149 -0
  45. package/src/ReorderList/ReorderList.test.tsx +190 -0
  46. package/src/ReorderList/ReorderList.tsx +331 -0
  47. package/src/icons.storyHelpers.tsx +12 -0
  48. package/src/index.ts +17 -0
  49. package/src/sourceRules.test.ts +37 -1
@@ -0,0 +1,157 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as NavBarStories from "./NavBar.stories";
4
+
5
+ <Meta of={NavBarStories} />
6
+
7
+ # NavBar
8
+
9
+ The app's destinations as real links, in one line — folding into a single menu button,
10
+ **whole**, when that line does not fit.
11
+
12
+ <Canvas of={NavBarStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## It folds whole, and that is the decision
19
+
20
+ `Toolbar` collapses **progressively**: items drop off the end one at a time, because its
21
+ items are ranked — the two things you do to a running job matter more than the two you do
22
+ to the deck.
23
+
24
+ A nav is not ranked. Its order is the shape of the product. Docket's reads as the pipeline
25
+ work moves along — Triage → Backlog → Phases → Lanes → Board → Archives — and a
26
+ half-collapsed bar splits that shape across two places with no rule a reader can learn.
27
+ "Some of the app is up here and the rest is behind a button" is worse than either whole
28
+ answer.
29
+
30
+ So the bar shows **every** destination or **none**, and the menu is the complete list in
31
+ the product's own order.
32
+
33
+ <Canvas of={NavBarStories.AllVariants} />
34
+
35
+ ## Why this is not a folded `Toolbar`
36
+
37
+ `Toolbar` already solves collapse-into-an-overflow, and this component **reuses its
38
+ measurement** — `useToolbarOverflow`, the same tape measure — rather than growing a second
39
+ one.
40
+
41
+ What it cannot reuse is the rendering. A `ToolbarAction` is `{ label, onSelect }`, and a
42
+ collapsed one becomes a `MenuItem`, which is also `{ label, onSelect }`. A nav folded into
43
+ one is a row of `<button>`s:
44
+
45
+ | | `<button onClick={navigate}>` | `<a href>` |
46
+ | --- | --- | --- |
47
+ | Middle-click, ctrl-click | nothing | opens a tab |
48
+ | "Open link in new tab" | absent from the menu | there |
49
+ | "Copy link address" | absent | there |
50
+ | Status bar on hover | blank | the destination |
51
+ | Visited styling, the back button's idea of history | none | the platform's |
52
+
53
+ `BuildingAnApp.mdx` names `<button onClick={navigate}>` as the fleet's most common
54
+ navigation defect. A collapse mechanism that introduced it below a certain width would be
55
+ that defect on a timer — and it screenshots identically, which is how it would survive
56
+ review.
57
+
58
+ The panel is a `Popover` (`role="dialog"`) rather than a `Menu` for the same reason:
59
+ `role="menu"` permits only the `menuitem` family, and a `menuitem` is something you **do**,
60
+ not somewhere you **go**.
61
+
62
+ ## Measured, not breakpointed
63
+
64
+ There is no `collapseAt` prop and no media query. The bar reads the width its links
65
+ actually have and folds when their sum stops fitting — so a longer label, a tenth
66
+ destination, `data-density="kiosk"`, or a reader at 175% browser zoom each move the fold on
67
+ their own.
68
+
69
+ mux-magic collapses at a hardcoded `480px` and plex-channels at `760px`. Neither number has
70
+ a reason, and both are wrong the first time anything about the content changes.
71
+
72
+ <Canvas of={NavBarStories.Responsive} />
73
+
74
+ ## ⚠️ The bar must not be sized by its own contents
75
+
76
+ It measures its own box to decide what to draw in it, so a container that shrink-wraps
77
+ makes the answer its own input: fewer links → a narrower box → fewer links.
78
+
79
+ The root carries `min-w-0 flex-1`, which is the whole fix inside a flex row. A grid track is
80
+ the caller's to declare:
81
+
82
+ ```css
83
+ grid-template-columns: auto minmax(0, 1fr) auto; /* brand, nav, actions */
84
+ ```
85
+
86
+ `auto` on that middle track is the bug — it floors at the nav's min-content width, which is
87
+ the sum of every link.
88
+
89
+ ## Which destination is current
90
+
91
+ The library is router-agnostic (see `RouterLinkProvider`), so it cannot ask a router which
92
+ link is active. The app hands it one string:
93
+
94
+ ```tsx
95
+ <NavBar currentHref={useLocation().pathname} items={DESTINATIONS} label="Main" />
96
+ ```
97
+
98
+ `getIsCurrentHref` decides the rest, and it is exported so an app can ask the same question
99
+ elsewhere:
100
+
101
+ - **A parent path is current for its children.** `/tasks` is current on `/tasks/41`,
102
+ because a nav item names a section and a section does not stop being the one you are in
103
+ when you open something inside it. The match is on whole segments, so `/task` is not
104
+ current for `/tasks`.
105
+ - **`/` is exact.** react-router's `<NavLink to="/">` is active on *every* route unless the
106
+ caller remembers `end`, which is the footgun behind every "why is Home always
107
+ highlighted" bug. There is no prop to change it here: a nav with two current items has
108
+ stopped saying anything.
109
+ - **The query string and the fragment are not part of the answer.**
110
+ `/backlog?focus=search` is still the Backlog.
111
+
112
+ The current destination gets `aria-current="page"` as well as the fill, because a highlight
113
+ is invisible to a screen reader and to anyone who cannot separate the two surfaces.
114
+
115
+ ## Mounted exactly once
116
+
117
+ A destination is in the bar or in the panel, never both.
118
+
119
+ The fleet's habit is to render the whole nav twice and hide one copy with `hidden md:flex`
120
+ — mux-magic's `PageHeader` across two ~55-line blocks, mail-sifter's `TriageQueue` again.
121
+ Every link is then in the DOM at every width, so an agent driving the page finds two of
122
+ each and cannot tell which one a human can see, the two copies drift, and every test has to
123
+ be defensively scoped.
124
+
125
+ <Canvas of={NavBarStories.AllStates} />
126
+
127
+ ## A fold control belongs at an edge
128
+
129
+ `menuAlign="end"` puts the trigger beside the header's own actions rather than leaving it
130
+ hugging the wordmark with the whole track empty after it. It reaches **only** the folded
131
+ row, which holds exactly one item — so where the links sit when the bar is whole is not a
132
+ prop, and cannot be made inconsistent with the rest of the header.
133
+
134
+ ## The trigger's glyph is the app's
135
+
136
+ The library ships no icons, so `menuIcon` has no default — U+2630 renders as nothing where
137
+ the font lacks it, which includes this sandbox's chromium and the kiosk image. With no icon
138
+ the trigger is a text button reading `menuLabel`, which works everywhere.
139
+
140
+ `menuLabel` names the trigger **and** the panel it opens: a `role="dialog"` takes no
141
+ accessible name from its content.
142
+
143
+ ## Keyboard
144
+
145
+ Every link in the bar is its own tab stop, which is what a `<nav>` is — this is not a
146
+ `role="toolbar"`, and there is no roving focus to learn.
147
+
148
+ Folded, it is one tab stop: Enter or Space opens the panel, focus moves into it, Escape
149
+ closes it and hands focus back to the trigger. Choosing a destination closes it too —
150
+ unless the click was modified, because a ctrl-click leaves the reader exactly where they
151
+ were and shutting the menu they are still reading is how people stop ctrl-clicking.
152
+
153
+ <Canvas of={NavBarStories.Interactive} />
154
+
155
+ ## In a shell
156
+
157
+ <Canvas of={NavBarStories.InAppShell} />
@@ -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
+ }