@charcuterie/ui 3.22.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.
- package/README.md +11 -0
- package/dist/Board/Board.d.ts +6 -3
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +19 -1
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +42 -7
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +54 -8
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/Board/useBoardDrag.d.ts +15 -1
- package/dist/Board/useBoardDrag.d.ts.map +1 -1
- package/dist/Board/useBoardDrag.js +3 -3
- package/dist/Board/useBoardDrag.js.map +1 -1
- package/dist/Nav/Nav.d.ts +167 -0
- package/dist/Nav/Nav.d.ts.map +1 -0
- package/dist/Nav/Nav.js +165 -0
- package/dist/Nav/Nav.js.map +1 -0
- package/dist/Nav/NavLink.d.ts +43 -0
- package/dist/Nav/NavLink.d.ts.map +1 -0
- package/dist/Nav/NavLink.js +111 -0
- package/dist/Nav/NavLink.js.map +1 -0
- package/dist/Nav/navItems.d.ts +67 -0
- package/dist/Nav/navItems.d.ts.map +1 -0
- package/dist/Nav/navItems.js +104 -0
- package/dist/Nav/navItems.js.map +1 -0
- package/dist/Nav/useNavLayout.d.ts +86 -0
- package/dist/Nav/useNavLayout.d.ts.map +1 -0
- package/dist/Nav/useNavLayout.js +114 -0
- package/dist/Nav/useNavLayout.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +37 -111
- package/dist/NavBar/NavBar.d.ts.map +1 -1
- package/dist/NavBar/NavBar.js +30 -110
- package/dist/NavBar/NavBar.js.map +1 -1
- package/dist/ReorderList/ReorderList.d.ts +131 -0
- package/dist/ReorderList/ReorderList.d.ts.map +1 -0
- package/dist/ReorderList/ReorderList.js +154 -0
- package/dist/ReorderList/ReorderList.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +25 -2
- package/src/Board/Board.stories.tsx +52 -1
- package/src/Board/Board.test.tsx +82 -0
- package/src/Board/Board.tsx +24 -4
- package/src/Board/BoardCard.tsx +103 -21
- package/src/Board/boardMoveHandleContainer.test.ts +54 -0
- package/src/Board/useBoardDrag.ts +17 -2
- package/src/Nav/Nav.mdx +169 -0
- package/src/Nav/Nav.stories.tsx +402 -0
- package/src/Nav/Nav.test.tsx +338 -0
- package/src/Nav/Nav.tsx +406 -0
- package/src/Nav/NavLink.tsx +220 -0
- package/src/Nav/navItems.test.ts +163 -0
- package/src/Nav/navItems.ts +175 -0
- package/src/Nav/useNavLayout.ts +197 -0
- package/src/NavBar/NavBar.mdx +26 -0
- package/src/NavBar/NavBar.tsx +54 -240
- package/src/ReorderList/ReorderList.mdx +83 -0
- package/src/ReorderList/ReorderList.stories.tsx +149 -0
- package/src/ReorderList/ReorderList.test.tsx +190 -0
- package/src/ReorderList/ReorderList.tsx +331 -0
- package/src/index.ts +26 -1
- package/src/sourceRules.test.ts +26 -1
- package/dist/NavBar/NavBarLink.d.ts +0 -43
- package/dist/NavBar/NavBarLink.d.ts.map +0 -1
- package/dist/NavBar/NavBarLink.js +0 -67
- package/dist/NavBar/NavBarLink.js.map +0 -1
- package/dist/NavBar/navBarCurrent.d.ts +0 -37
- package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
- package/dist/NavBar/navBarCurrent.js +0 -48
- package/dist/NavBar/navBarCurrent.js.map +0 -1
- package/src/NavBar/NavBarLink.tsx +0 -139
- package/src/NavBar/navBarCurrent.test.ts +0 -81
- package/src/NavBar/navBarCurrent.ts +0 -64
package/src/Board/BoardCard.tsx
CHANGED
|
@@ -147,13 +147,16 @@ export type BoardCardProps = {
|
|
|
147
147
|
item: BoardItem
|
|
148
148
|
laneLabel: string
|
|
149
149
|
/**
|
|
150
|
-
* Shown on the move handle
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
150
|
+
* Shown on the move handle **while the lanes are side by side**,
|
|
151
|
+
* instead of the word "Move". Below the board's `cq-lg` the word
|
|
152
|
+
* comes back whatever this is — see the handle section below.
|
|
153
|
+
*
|
|
154
|
+
* The library ships no icons, so the default is words — a `⋮`
|
|
155
|
+
* renders as nothing where the font lacks it, which includes the
|
|
156
|
+
* kiosk image and the ePaper build. An app that already owns a
|
|
157
|
+
* glyph set passes one and buys back the ~35px per row that a
|
|
158
|
+
* word costs, which in a 500px lane is several characters of
|
|
159
|
+
* title.
|
|
157
160
|
*/
|
|
158
161
|
moveIcon?: ReactNode
|
|
159
162
|
/** The lanes this card can be sent to — never its own. */
|
|
@@ -205,6 +208,38 @@ export type BoardCardProps = {
|
|
|
205
208
|
* on screen at all. Dragging from it moves the card directly. Both
|
|
206
209
|
* commit through the same callback, so there is no second code path
|
|
207
210
|
* to keep honest and neither is a fallback for the other.
|
|
211
|
+
*
|
|
212
|
+
* ### What it WEARS depends on whether a drag can succeed
|
|
213
|
+
*
|
|
214
|
+
* The two drivers are not equally available at every width, so the
|
|
215
|
+
* handle does not claim they are:
|
|
216
|
+
*
|
|
217
|
+
* | The board is | The handle shows | Because |
|
|
218
|
+
* | --- | --- | --- |
|
|
219
|
+
* | `cq-lg` and wider — lanes side by side | the app's `moveIcon` | the other lanes are on screen, so a drag has somewhere to land, and a grip glyph teaches that gesture |
|
|
220
|
+
* | narrower — one lane and a segmented control | the word **Move** | there is nothing on screen to drop onto, so the only gesture the glyph teaches is the one that cannot work |
|
|
221
|
+
*
|
|
222
|
+
* That table is a bug report. QueuePilot shipped a `≡` handle in a
|
|
223
|
+
* one-lane board and the owner could not move anybody: *"I can't
|
|
224
|
+
* seem to drag 'n drop the names from Everyone Else anywhere else.
|
|
225
|
+
* There's no right-click or anything. How do I move these?"* The
|
|
226
|
+
* first fix took the glyph away everywhere, which was the wrong
|
|
227
|
+
* half — *"I think the drag handles were fine, but now you have it
|
|
228
|
+
* in a 3-column mode, so dragging would work, but it has this
|
|
229
|
+
* 'move' button instead."*
|
|
230
|
+
*
|
|
231
|
+
* Both are in the DOM and CSS picks; neither is rendered from a
|
|
232
|
+
* measurement. A `ResizeObserver` would get the first paint wrong,
|
|
233
|
+
* and a kiosk Pi shows that. Both are `aria-hidden`, and the
|
|
234
|
+
* handle's whole accessible name comes from the `VisuallyHidden`
|
|
235
|
+
* beside them — so the name is the same sentence at every width,
|
|
236
|
+
* which is what keeps one `getByRole` query working in both
|
|
237
|
+
* layouts.
|
|
238
|
+
*
|
|
239
|
+
* The query is against the **board's** container, named `board`,
|
|
240
|
+
* not the lane's. A lane in a three-up board is narrow and the
|
|
241
|
+
* Narrow View's single lane is wide, so the nearer container
|
|
242
|
+
* answers this question exactly backwards.
|
|
208
243
|
*/
|
|
209
244
|
export const BoardCard = ({
|
|
210
245
|
item,
|
|
@@ -346,12 +381,29 @@ export const BoardCard = ({
|
|
|
346
381
|
<Button
|
|
347
382
|
appearance="ghost"
|
|
348
383
|
intent="neutral"
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
384
|
+
className={toClassName(
|
|
385
|
+
// `touch-none` so a touch drag is a drag
|
|
386
|
+
// rather than a page scroll. Only the handle
|
|
387
|
+
// needs it; the rest of the gesture is
|
|
388
|
+
// covered by `preventDefault` on the
|
|
389
|
+
// captured `pointermove`.
|
|
390
|
+
"touch-none opacity-70 group-hover:opacity-100",
|
|
391
|
+
// `sizing` is a prop and cannot be two
|
|
392
|
+
// things at once, so the button stays
|
|
393
|
+
// `control`-sized — which is what the word
|
|
394
|
+
// needs — and takes `icon` sizing back at
|
|
395
|
+
// the width where the glyph is what is
|
|
396
|
+
// showing. These are the two utilities
|
|
397
|
+
// `ICON_CONTROL_SIZE_CLASS.sm` is, minus the
|
|
398
|
+
// height `CONTROL_SIZE_CLASS.sm` already
|
|
399
|
+
// set. Deterministic rather than a
|
|
400
|
+
// specificity coin-flip: Tailwind orders a
|
|
401
|
+
// variant after the utility it varies, which
|
|
402
|
+
// is the same thing the lane's own
|
|
403
|
+
// `cq-lg:p-0` relies on.
|
|
404
|
+
moveIcon != null &&
|
|
405
|
+
"@min-[48rem]/board:w-(--control-height-sm) @min-[48rem]/board:px-0",
|
|
406
|
+
)}
|
|
355
407
|
onClick={() => {
|
|
356
408
|
setIsMenuVisible(
|
|
357
409
|
(isVisible) => !isVisible,
|
|
@@ -361,23 +413,53 @@ export const BoardCard = ({
|
|
|
361
413
|
onStartDrag?.(pointerEvent)
|
|
362
414
|
}}
|
|
363
415
|
size="sm"
|
|
364
|
-
sizing=
|
|
416
|
+
sizing="control"
|
|
365
417
|
>
|
|
366
|
-
{moveIcon
|
|
418
|
+
{moveIcon == null ? null : (
|
|
419
|
+
// `hidden` first, so a browser with no
|
|
420
|
+
// container-query support — and every test
|
|
421
|
+
// renderer that does not lay out — keeps the
|
|
422
|
+
// word, which is the affordance that works
|
|
423
|
+
// everywhere.
|
|
424
|
+
//
|
|
425
|
+
// `contents` rather than `inline`: the glyph
|
|
426
|
+
// stays a direct flex child of the button,
|
|
427
|
+
// so it centres the way it did when it was
|
|
428
|
+
// the button's only content.
|
|
429
|
+
<span
|
|
430
|
+
aria-hidden="true"
|
|
431
|
+
className="hidden @min-[48rem]/board:contents"
|
|
432
|
+
>
|
|
433
|
+
{moveIcon}
|
|
434
|
+
</span>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
<span
|
|
438
|
+
aria-hidden="true"
|
|
439
|
+
className={toClassName(
|
|
440
|
+
moveIcon != null &&
|
|
441
|
+
"@min-[48rem]/board:hidden",
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
Move
|
|
445
|
+
</span>
|
|
367
446
|
|
|
368
447
|
{/*
|
|
369
448
|
* Thirty controls all named "Move" is thirty
|
|
370
449
|
* indistinguishable buttons to a screen reader
|
|
371
450
|
* and to `getByRole("button", { name })`, so
|
|
372
451
|
* the accessible name says *which* card and
|
|
373
|
-
* where it currently is.
|
|
374
|
-
*
|
|
375
|
-
*
|
|
452
|
+
* where it currently is.
|
|
453
|
+
*
|
|
454
|
+
* The whole name lives here, and both visible
|
|
455
|
+
* affordances are `aria-hidden`, because which
|
|
456
|
+
* of them is painted is a CSS answer that the
|
|
457
|
+
* accessible name must not depend on — a
|
|
458
|
+
* handle called "Move X" wide and "Move Move
|
|
459
|
+
* X" narrow is one control with two names.
|
|
376
460
|
*/}
|
|
377
461
|
<VisuallyHidden>
|
|
378
|
-
{
|
|
379
|
-
? `Move ${item.title}, currently in ${laneLabel}`
|
|
380
|
-
: ` ${item.title}, currently in ${laneLabel}`}
|
|
462
|
+
{`Move ${item.title}, currently in ${laneLabel}`}
|
|
381
463
|
</VisuallyHidden>
|
|
382
464
|
</Button>
|
|
383
465
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { containerQuery } from "@charcuterie/tokens/src/scales.ts"
|
|
5
|
+
import { expect, test } from "vitest"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The move handle swaps its glyph for the word "Move" at the width
|
|
9
|
+
* where the lanes stop being side by side — and it has to be the
|
|
10
|
+
* SAME width the lanes themselves use, or there is a band where the
|
|
11
|
+
* board is one lane and the handle still teaches a drag.
|
|
12
|
+
*
|
|
13
|
+
* Two literals cannot be shared: a container query's threshold is
|
|
14
|
+
* resolved before custom properties exist, so
|
|
15
|
+
* `@container (min-inline-size: var(--cq-lg))` is invalid CSS and
|
|
16
|
+
* `48rem` has to be written out. The lanes get theirs from the
|
|
17
|
+
* generated `cq-lg` variant; the handle writes Tailwind's own
|
|
18
|
+
* `@min-[…]/board:`, because that is the only form that can name a
|
|
19
|
+
* container. This test is what keeps the two from drifting.
|
|
20
|
+
*/
|
|
21
|
+
const directory = import.meta.dirname
|
|
22
|
+
|
|
23
|
+
test("the handle's own breakpoint is the scale's `cq-lg`, spelled out", async () => {
|
|
24
|
+
const source = await readFile(
|
|
25
|
+
join(directory, "BoardCard.tsx"),
|
|
26
|
+
"utf8",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const thresholds = [
|
|
30
|
+
...source.matchAll(/@min-\[([^\]]+)\]\/board:/g),
|
|
31
|
+
].map((match) => match[1])
|
|
32
|
+
|
|
33
|
+
// The mutation guard: a refactor that renames the variant would
|
|
34
|
+
// otherwise turn the assertion below into `[] === []`.
|
|
35
|
+
expect(thresholds.length).toBeGreaterThan(0)
|
|
36
|
+
|
|
37
|
+
expect([...new Set(thresholds)]).toEqual([
|
|
38
|
+
containerQuery.lg,
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The other half. A named query with no container of that name
|
|
44
|
+
* never matches anything — no error, no warning, and the handle
|
|
45
|
+
* silently keeps whichever affordance the base classes gave it.
|
|
46
|
+
*/
|
|
47
|
+
test("the board declares the `board` container the handle queries", async () => {
|
|
48
|
+
const source = await readFile(
|
|
49
|
+
join(directory, "Board.tsx"),
|
|
50
|
+
"utf8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(source).toContain('"@container/board"')
|
|
54
|
+
})
|
|
@@ -140,8 +140,23 @@ export const getIndicatorOffset = (
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
export const useBoardDrag = ({
|
|
143
|
+
itemSelector = "[data-board-card]",
|
|
143
144
|
onDrop,
|
|
144
145
|
}: {
|
|
146
|
+
/**
|
|
147
|
+
* How a registered list's draggable children are found, so a
|
|
148
|
+
* consumer that is not a board can reuse this geometry instead
|
|
149
|
+
* of forking it. `ReorderList` passes `[data-reorder-item]` and
|
|
150
|
+
* registers a single lane; the default is the board's own
|
|
151
|
+
* marker, so `Board` reads exactly as it did before this
|
|
152
|
+
* parameter existed.
|
|
153
|
+
*
|
|
154
|
+
* It is a `data-` attribute on both sides rather than a class,
|
|
155
|
+
* for the reason `BoardCard` already gives: a class is a style
|
|
156
|
+
* and a consumer's tooling may rename it, and this is a
|
|
157
|
+
* contract with a hit test.
|
|
158
|
+
*/
|
|
159
|
+
itemSelector?: string
|
|
145
160
|
onDrop: (
|
|
146
161
|
origin: BoardDragOrigin,
|
|
147
162
|
target: BoardDropTarget,
|
|
@@ -207,7 +222,7 @@ export const useBoardDrag = ({
|
|
|
207
222
|
laneElements.current,
|
|
208
223
|
([key, element]) => {
|
|
209
224
|
const cardElements = Array.from(
|
|
210
|
-
element.querySelectorAll(
|
|
225
|
+
element.querySelectorAll(itemSelector),
|
|
211
226
|
)
|
|
212
227
|
|
|
213
228
|
return {
|
|
@@ -222,7 +237,7 @@ export const useBoardDrag = ({
|
|
|
222
237
|
}
|
|
223
238
|
},
|
|
224
239
|
)
|
|
225
|
-
}, [])
|
|
240
|
+
}, [itemSelector])
|
|
226
241
|
|
|
227
242
|
const moveTo = useCallback(
|
|
228
243
|
(point: { x: number; y: number }) => {
|
package/src/Nav/Nav.mdx
ADDED
|
@@ -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.
|