@charcuterie/ui 3.20.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.
Files changed (43) hide show
  1. package/README.md +11 -0
  2. package/dist/Board/BoardCard.d.ts +42 -18
  3. package/dist/Board/BoardCard.d.ts.map +1 -1
  4. package/dist/Board/BoardCard.js +17 -1
  5. package/dist/Board/BoardCard.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/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/Board/Board.stories.tsx +100 -0
  28. package/src/Board/Board.test.tsx +62 -0
  29. package/src/Board/BoardCard.tsx +71 -18
  30. package/src/DropRail/DropRail.mdx +90 -0
  31. package/src/DropRail/DropRail.stories.tsx +328 -0
  32. package/src/DropRail/DropRail.test.tsx +154 -0
  33. package/src/DropRail/DropRail.tsx +496 -0
  34. package/src/NavBar/NavBar.mdx +157 -0
  35. package/src/NavBar/NavBar.stories.tsx +350 -0
  36. package/src/NavBar/NavBar.test.tsx +344 -0
  37. package/src/NavBar/NavBar.tsx +271 -0
  38. package/src/NavBar/NavBarLink.tsx +139 -0
  39. package/src/NavBar/navBarCurrent.test.ts +81 -0
  40. package/src/NavBar/navBarCurrent.ts +64 -0
  41. package/src/icons.storyHelpers.tsx +12 -0
  42. package/src/index.ts +11 -0
  43. package/src/sourceRules.test.ts +24 -1
@@ -0,0 +1,496 @@
1
+ import {
2
+ useLatestRef,
3
+ useRovingFocus,
4
+ useUniqueId,
5
+ } from "@charcuterie/logic"
6
+ import type { ReactNode } from "react"
7
+ import { useEffect, useRef, useState } from "react"
8
+
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
11
+
12
+ export type DropRailTarget = {
13
+ /**
14
+ * A number after the label — a lane's size, a project's open
15
+ * count. Optional, and absent is a real answer: a rail of
16
+ * destinations that have no size should not print a zero on every
17
+ * chip.
18
+ */
19
+ count?: number
20
+ /**
21
+ * Where the thing being moved already is. It stays **visible and
22
+ * unofferable** rather than being filtered out, because a rail
23
+ * whose contents change depending on what you grabbed is a rail
24
+ * whose chips move under the pointer between one move and the
25
+ * next — and muscle memory is the only reason this control is
26
+ * faster than a menu.
27
+ */
28
+ isCurrent?: boolean
29
+ isDisabled?: boolean
30
+ key: string
31
+ label: string
32
+ /** A leading glyph the app owns — an emoji, a dot, an icon. */
33
+ mark?: ReactNode
34
+ }
35
+
36
+ export type DropRailProps = {
37
+ className?: string
38
+ /**
39
+ * Whether the rail is showing. The host owns this, because only
40
+ * the host knows a move started — and it is the same boolean
41
+ * whether the move began as a pointer drag or as a tap on a
42
+ * handle.
43
+ */
44
+ isOpen: boolean
45
+ /** The rail's accessible name. Required — it is a listbox. */
46
+ label: string
47
+ onDismiss?: () => void
48
+ onPick: (targetKey: string) => void
49
+ targets: readonly DropRailTarget[]
50
+ }
51
+
52
+ /**
53
+ * The attribute the pointer hit test reads. A `data-` attribute
54
+ * rather than a class, for the same reason `Board` marks its cards
55
+ * with one: a class is a style and may be renamed by a consumer's
56
+ * tooling, and this is a contract with a hit test.
57
+ */
58
+ const TARGET_ATTRIBUTE = "data-drop-rail-target"
59
+
60
+ const TARGET_SELECTOR = "[data-drop-rail-target]"
61
+
62
+ /**
63
+ * Every destination at once, brought to the pointer instead of the
64
+ * pointer being carried to it.
65
+ *
66
+ * ### The measurement this exists for
67
+ *
68
+ * `Board` moves a card with a `Menu` on the card, and that is the
69
+ * right control for a board: three or four lanes, all on screen, and
70
+ * the menu opens where your hand already is. It stops being the
71
+ * right control at the size the fleet's real lists reach. Docket's
72
+ * Backlog draws **34 project groups down one page**, of which about
73
+ * four fit on a display. Dragging a card from the first to the
74
+ * thirty-fourth means holding a pointer down while the page scrolls,
75
+ * and a menu of 34 items is a scrolling list inside a popup — the
76
+ * same problem in a smaller box.
77
+ *
78
+ * A rail answers both. It pins to the top for the length of the
79
+ * move, so nothing is ever off screen, and there is no travel: every
80
+ * destination is the same short distance away.
81
+ *
82
+ * ### One control, three drivers, and that is the whole design
83
+ *
84
+ * WCAG 2.5.7 requires a single-pointer alternative to any dragging
85
+ * movement, and `FileDropZone` next door says the same thing about
86
+ * its own gesture: build the accessible path first and let the drag
87
+ * be the enhancement. So the rail is **not** a drag affordance with
88
+ * a fallback bolted on. It is a listbox that three things drive:
89
+ *
90
+ * 1. **Pointer.** While a button is held, the chip under the
91
+ * pointer is active and releasing over it commits.
92
+ * 2. **Tap.** Opened with no button held, it is a list you touch.
93
+ * Identical markup, identical commit.
94
+ * 3. **Keyboard.** Arrow keys walk it, Enter commits, Escape
95
+ * abandons. `useRovingFocus` owns which chip is tabbable, so
96
+ * there is no second keyboard model in this file to drift out of
97
+ * step with `Listbox`.
98
+ *
99
+ * The rail does not know which of the three opened it, and the host
100
+ * does not have to say. It reads `event.buttons` on the first
101
+ * pointer event it sees: a button already down means a drag is in
102
+ * flight, and nothing else needs threading through.
103
+ *
104
+ * ### It takes no drag-and-drop dependency, and it hit-tests chips
105
+ *
106
+ * The board's decision priced four libraries and took none of them,
107
+ * because a published library's dependency list is inherited by
108
+ * every consumer — including one with 60 KB gzipped to spend in
109
+ * total. Nothing here changes that arithmetic, and this needs far
110
+ * less than `Board` does: a chip is a box under the pointer, so
111
+ * `elementFromPoint` answers the only geometric question there is.
112
+ * No snapshot, no collision strategy, no sortable transform.
113
+ *
114
+ * ### It is not `Rail`, and the two must not be confused
115
+ *
116
+ * `Rail` is the app shell's side rail — a landmark, an `<aside>` or
117
+ * a `<nav>`, permanent furniture down one edge of a page. This is a
118
+ * horizontal strip of drop targets that exists only for the length
119
+ * of a move, and it is a `listbox`. They share four letters and
120
+ * nothing else. `FileDropZone` sets the precedent for the prefix
121
+ * carrying the distinction.
122
+ *
123
+ * ### What it deliberately does not do
124
+ *
125
+ * It does not render the thing being dragged, own the handle, or
126
+ * know what a move means. The host keeps all three, which is what
127
+ * lets the same rail sit above a board, a card grid, or a list of
128
+ * accordions without any of them being modelled here.
129
+ */
130
+ export const DropRail = ({
131
+ className,
132
+ isOpen,
133
+ label,
134
+ onDismiss,
135
+ onPick,
136
+ targets,
137
+ }: DropRailProps): ReactNode => {
138
+ const baseId = useUniqueId()
139
+
140
+ const railRef = useRef<HTMLDivElement>(null)
141
+
142
+ /**
143
+ * A pointer button was already down when the rail opened, so this
144
+ * move is a drag and it commits on release. A tap-opened rail
145
+ * commits on click instead, and the two must not both fire — a
146
+ * released drag also produces a `click`.
147
+ */
148
+ const isPointerDriven = useRef(false)
149
+
150
+ /**
151
+ * Focus is moved into the rail once per open, not once per render.
152
+ * Without the latch the effect below re-runs whenever the host
153
+ * re-renders and drags focus back to the first chip, which
154
+ * undoes every arrow key the moment anything above updates.
155
+ */
156
+ const hasTakenFocus = useRef(false)
157
+
158
+ const [announcement, setAnnouncement] = useState("")
159
+
160
+ const offerableKeys = targets
161
+ .filter(
162
+ (target) => !target.isCurrent && !target.isDisabled,
163
+ )
164
+ .map((target) => target.key)
165
+
166
+ /**
167
+ * The offerable keys as one string, because the effects below
168
+ * depend on *which* destinations are offerable and none of them
169
+ * may depend on the array's identity.
170
+ *
171
+ * `targets` is almost always a literal in the host's render, so
172
+ * the array is new on every render. An effect keyed on it
173
+ * re-registers every chip each time, and re-registering loses the
174
+ * active one — which during a keyboard walk means the arrow keys
175
+ * stop moving for no reason a consumer could ever debug.
176
+ */
177
+ const offerableKeysText = offerableKeys.join(" ")
178
+
179
+ const firstOfferableKey = offerableKeys[0]
180
+
181
+ const {
182
+ activeValue,
183
+ next,
184
+ previous,
185
+ register,
186
+ setActiveValue,
187
+ } = useRovingFocus<string>({ isWrapping: true })
188
+
189
+ /**
190
+ * Read by the global listeners, which are registered once per
191
+ * open rather than once per render. Without them the pointer
192
+ * handler closes over a stale `activeValue`, and Enter commits
193
+ * whichever chip was active when the rail opened.
194
+ */
195
+ const activeValueRef = useLatestRef(activeValue)
196
+
197
+ const offerableKeysRef = useLatestRef(offerableKeys)
198
+
199
+ const onDismissRef = useLatestRef(onDismiss)
200
+
201
+ const onPickRef = useLatestRef(onPick)
202
+
203
+ const targetsRef = useLatestRef(targets)
204
+
205
+ useEffect(() => {
206
+ const releases = offerableKeysText
207
+ .split(" ")
208
+ .filter((key) => key !== "")
209
+ .map((key) => register(key))
210
+
211
+ return () => {
212
+ for (const release of releases) {
213
+ release()
214
+ }
215
+ }
216
+ }, [offerableKeysText, register])
217
+
218
+ /**
219
+ * Closing resets everything a reopen must not inherit. Without it
220
+ * a rail reopened for a different card starts on whatever the last
221
+ * move chose, and the first arrow key moves from a place the
222
+ * person never went.
223
+ */
224
+ useEffect(() => {
225
+ if (isOpen) {
226
+ return
227
+ }
228
+
229
+ setActiveValue(null)
230
+
231
+ isPointerDriven.current = false
232
+
233
+ hasTakenFocus.current = false
234
+
235
+ setAnnouncement("")
236
+ }, [isOpen, setActiveValue])
237
+
238
+ useEffect(() => {
239
+ if (!isOpen) {
240
+ return undefined
241
+ }
242
+
243
+ const commit = (targetKey: string) => {
244
+ const target = targetsRef.current.find(
245
+ (one) => one.key === targetKey,
246
+ )
247
+
248
+ if (target) {
249
+ setAnnouncement(`Moved to ${target.label}.`)
250
+ }
251
+
252
+ onPickRef.current(targetKey)
253
+ }
254
+
255
+ const getKeyAt = (clientX: number, clientY: number) => {
256
+ const chip = globalThis.document
257
+ .elementFromPoint(clientX, clientY)
258
+ ?.closest(TARGET_SELECTOR)
259
+
260
+ return chip?.getAttribute(TARGET_ATTRIBUTE) ?? null
261
+ }
262
+
263
+ const getIsOfferable = (key: string | null) =>
264
+ key !== null && offerableKeysRef.current.includes(key)
265
+
266
+ const onPointerMove = (pointerEvent: PointerEvent) => {
267
+ if (pointerEvent.buttons === 0) {
268
+ return
269
+ }
270
+
271
+ isPointerDriven.current = true
272
+
273
+ const key = getKeyAt(
274
+ pointerEvent.clientX,
275
+ pointerEvent.clientY,
276
+ )
277
+
278
+ // `null` when the pointer is between chips or off the rail
279
+ // entirely, and that has to clear the highlight. A rail that
280
+ // keeps the last chip lit is telling you it will land
281
+ // somewhere it will not.
282
+ setActiveValue(getIsOfferable(key) ? key : null)
283
+ }
284
+
285
+ const onPointerUp = (pointerEvent: PointerEvent) => {
286
+ if (!isPointerDriven.current) {
287
+ return
288
+ }
289
+
290
+ const key = getKeyAt(
291
+ pointerEvent.clientX,
292
+ pointerEvent.clientY,
293
+ )
294
+
295
+ if (getIsOfferable(key) && key !== null) {
296
+ commit(key)
297
+
298
+ return
299
+ }
300
+
301
+ onDismissRef.current?.()
302
+ }
303
+
304
+ const onKeyDown = (keyEvent: KeyboardEvent) => {
305
+ if (keyEvent.key === "Escape") {
306
+ keyEvent.preventDefault()
307
+
308
+ onDismissRef.current?.()
309
+
310
+ return
311
+ }
312
+
313
+ if (
314
+ keyEvent.key === "ArrowRight" ||
315
+ keyEvent.key === "ArrowDown"
316
+ ) {
317
+ keyEvent.preventDefault()
318
+
319
+ next()
320
+
321
+ return
322
+ }
323
+
324
+ if (
325
+ keyEvent.key === "ArrowLeft" ||
326
+ keyEvent.key === "ArrowUp"
327
+ ) {
328
+ keyEvent.preventDefault()
329
+
330
+ previous()
331
+
332
+ return
333
+ }
334
+
335
+ const isCommitKey =
336
+ keyEvent.key === "Enter" || keyEvent.key === " "
337
+
338
+ if (isCommitKey && activeValueRef.current !== null) {
339
+ keyEvent.preventDefault()
340
+
341
+ commit(activeValueRef.current)
342
+ }
343
+ }
344
+
345
+ globalThis.addEventListener(
346
+ "pointermove",
347
+ onPointerMove,
348
+ )
349
+
350
+ globalThis.addEventListener("pointerup", onPointerUp)
351
+
352
+ globalThis.addEventListener("keydown", onKeyDown)
353
+
354
+ return () => {
355
+ globalThis.removeEventListener(
356
+ "pointermove",
357
+ onPointerMove,
358
+ )
359
+
360
+ globalThis.removeEventListener(
361
+ "pointerup",
362
+ onPointerUp,
363
+ )
364
+
365
+ globalThis.removeEventListener("keydown", onKeyDown)
366
+ }
367
+ }, [
368
+ activeValueRef,
369
+ isOpen,
370
+ next,
371
+ offerableKeysRef,
372
+ onDismissRef,
373
+ onPickRef,
374
+ previous,
375
+ setActiveValue,
376
+ targetsRef,
377
+ ])
378
+
379
+ /**
380
+ * Focus enters the rail only when a pointer is NOT driving it.
381
+ * Stealing focus mid-drag scrolls the page to the rail on some
382
+ * browsers, which is the one thing this control exists to avoid.
383
+ */
384
+ useEffect(() => {
385
+ if (
386
+ !isOpen ||
387
+ isPointerDriven.current ||
388
+ hasTakenFocus.current ||
389
+ firstOfferableKey === undefined
390
+ ) {
391
+ return
392
+ }
393
+
394
+ hasTakenFocus.current = true
395
+
396
+ setActiveValue(firstOfferableKey)
397
+
398
+ railRef.current
399
+ ?.querySelector<HTMLElement>(
400
+ "[data-drop-rail-target]:not([aria-disabled='true'])",
401
+ )
402
+ ?.focus()
403
+ }, [firstOfferableKey, isOpen, setActiveValue])
404
+
405
+ if (!isOpen) {
406
+ return null
407
+ }
408
+
409
+ return (
410
+ <>
411
+ <div
412
+ aria-label={label}
413
+ className={toClassName(
414
+ "sticky top-0 z-20 flex flex-wrap items-center gap-2 rounded-md border border-border-default bg-surface-overlay p-2 shadow-(--elevation-medium)",
415
+ className,
416
+ )}
417
+ id={baseId}
418
+ ref={railRef}
419
+ role="listbox"
420
+ >
421
+ {targets.map((target) => {
422
+ const isOfferable =
423
+ !target.isCurrent && !target.isDisabled
424
+
425
+ const isActive =
426
+ isOfferable && activeValue === target.key
427
+
428
+ return (
429
+ <button
430
+ aria-disabled={isOfferable ? undefined : true}
431
+ aria-selected={isActive}
432
+ className={toClassName(
433
+ "flex max-w-80 items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors duration-(--duration-fast) ease-standard",
434
+ isOfferable
435
+ ? "cursor-pointer border-border-default bg-surface-raised text-content-primary hover:bg-surface-sunken"
436
+ : "cursor-not-allowed border-border-subtle bg-surface-sunken text-content-disabled",
437
+ isActive &&
438
+ "border-intent-accent-border bg-intent-accent-surface text-intent-accent-content outline-solid outline-(length:--focus-ring-width) outline-focus-ring",
439
+ )}
440
+ data-drop-rail-target={target.key}
441
+ key={target.key}
442
+ onClick={() => {
443
+ // A released drag also fires a click. The pointer path
444
+ // has already committed by then, so this guard is what
445
+ // stops a drag committing twice.
446
+ if (
447
+ isPointerDriven.current ||
448
+ !isOfferable
449
+ ) {
450
+ return
451
+ }
452
+
453
+ onPick(target.key)
454
+ }}
455
+ role="option"
456
+ tabIndex={isActive ? 0 : -1}
457
+ type="button"
458
+ >
459
+ {target.mark === undefined ? null : (
460
+ <span aria-hidden>{target.mark}</span>
461
+ )}
462
+
463
+ <span className="truncate">
464
+ {target.label}
465
+ </span>
466
+
467
+ {target.count === undefined ? null : (
468
+ <span className="text-content-secondary text-xs">
469
+ {target.count}
470
+ </span>
471
+ )}
472
+
473
+ {target.isCurrent ? (
474
+ // Said out loud, not shown by dimming alone. WCAG
475
+ // 1.4.1: the one chip a person must not aim for is
476
+ // exactly the one that must not be marked by colour.
477
+ <VisuallyHidden>
478
+ — where it is now
479
+ </VisuallyHidden>
480
+ ) : null}
481
+ </button>
482
+ )
483
+ })}
484
+ </div>
485
+
486
+ {/* OUTSIDE the listbox, and that is not a style choice. A
487
+ `role="listbox"` may contain only `option` children, so a
488
+ live region inside it is an axe `aria-required-children`
489
+ violation — which is how this was found. It is `sr-only`,
490
+ so being a sibling costs no layout. */}
491
+ <output aria-live="polite" className="sr-only">
492
+ {announcement}
493
+ </output>
494
+ </>
495
+ )
496
+ }
@@ -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} />