@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.
- package/README.md +22 -0
- 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/DropRail/DropRail.d.ts +109 -0
- package/dist/DropRail/DropRail.d.ts.map +1 -0
- package/dist/DropRail/DropRail.js +277 -0
- package/dist/DropRail/DropRail.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +130 -0
- package/dist/NavBar/NavBar.d.ts.map +1 -0
- package/dist/NavBar/NavBar.js +114 -0
- package/dist/NavBar/NavBar.js.map +1 -0
- package/dist/NavBar/NavBarLink.d.ts +43 -0
- package/dist/NavBar/NavBarLink.d.ts.map +1 -0
- package/dist/NavBar/NavBarLink.js +67 -0
- package/dist/NavBar/NavBarLink.js.map +1 -0
- package/dist/NavBar/navBarCurrent.d.ts +37 -0
- package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
- package/dist/NavBar/navBarCurrent.js +48 -0
- package/dist/NavBar/navBarCurrent.js.map +1 -0
- 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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/useBoardDrag.ts +17 -2
- package/src/DropRail/DropRail.mdx +90 -0
- package/src/DropRail/DropRail.stories.tsx +328 -0
- package/src/DropRail/DropRail.test.tsx +154 -0
- package/src/DropRail/DropRail.tsx +496 -0
- package/src/NavBar/NavBar.mdx +157 -0
- package/src/NavBar/NavBar.stories.tsx +350 -0
- package/src/NavBar/NavBar.test.tsx +344 -0
- package/src/NavBar/NavBar.tsx +271 -0
- package/src/NavBar/NavBarLink.tsx +139 -0
- package/src/NavBar/navBarCurrent.test.ts +81 -0
- package/src/NavBar/navBarCurrent.ts +64 -0
- 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/icons.storyHelpers.tsx +12 -0
- package/src/index.ts +17 -0
- package/src/sourceRules.test.ts +37 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import {
|
|
3
|
+
expect,
|
|
4
|
+
userEvent,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from "storybook/test"
|
|
8
|
+
import { test } from "vitest"
|
|
9
|
+
|
|
10
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
11
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
12
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
13
|
+
import * as stories from "./NavBar.stories.tsx"
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
AllStates,
|
|
17
|
+
AllVariants,
|
|
18
|
+
Default,
|
|
19
|
+
InAppShell,
|
|
20
|
+
Interactive,
|
|
21
|
+
} = composeStories(stories)
|
|
22
|
+
|
|
23
|
+
test("the nav is named, and its destinations are links", async () => {
|
|
24
|
+
const { canvas } = await mountStory(Default)
|
|
25
|
+
|
|
26
|
+
const nav = expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Main",
|
|
28
|
+
role: "navigation",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const board = expectAgentDrivable(within(nav), {
|
|
32
|
+
name: "Board",
|
|
33
|
+
role: "link",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A `<button onClick={navigate}>` looks identical in a screenshot
|
|
37
|
+
// and satisfies "the nav works". It has no `href`, so middle-click,
|
|
38
|
+
// ctrl-click and "copy link address" all do nothing.
|
|
39
|
+
await expect(board).toHaveAttribute("href", "/board")
|
|
40
|
+
|
|
41
|
+
await expectNoAxeViolations(nav)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The trigger exists **only when the row did not fit**. mux-magic
|
|
46
|
+
* shows its overflow glyph at every width, which is why its collapse
|
|
47
|
+
* is not a collapse at all — almost everything lives behind the
|
|
48
|
+
* button permanently.
|
|
49
|
+
*/
|
|
50
|
+
test("a bar with room shows no menu trigger", async () => {
|
|
51
|
+
const { canvas } = await mountStory(Default)
|
|
52
|
+
|
|
53
|
+
const nav = expectAgentDrivable(canvas, {
|
|
54
|
+
name: "Main",
|
|
55
|
+
role: "navigation",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(
|
|
60
|
+
within(nav).queryByRole("button", {
|
|
61
|
+
name: "Main menu",
|
|
62
|
+
}),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
within(nav).getAllByRole("link"),
|
|
68
|
+
).toHaveLength(9)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* All or nothing — the decision this component exists to hold.
|
|
73
|
+
*
|
|
74
|
+
* `Toolbar` would leave four of the nine in the bar at this width
|
|
75
|
+
* and put five behind the button, which splits the product's own
|
|
76
|
+
* order across two places with no rule the reader can learn.
|
|
77
|
+
*/
|
|
78
|
+
test("a bar that does not fit folds every destination together", async () => {
|
|
79
|
+
const { canvas } = await mountStory(AllVariants)
|
|
80
|
+
|
|
81
|
+
const nav = expectAgentDrivable(canvas, {
|
|
82
|
+
name: "Main, narrow",
|
|
83
|
+
role: "navigation",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expectAgentDrivable(within(nav), {
|
|
87
|
+
name: "Main menu",
|
|
88
|
+
role: "button",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Not "fewer links" — no links.
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(within(nav).queryAllByRole("link")).toHaveLength(
|
|
94
|
+
0,
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The defect this component refuses to introduce, and the reason it
|
|
101
|
+
* is not a folded `Toolbar`: `MenuItem` is `{ label, onSelect }`, so
|
|
102
|
+
* every row in the panel would be a `<button>` — no new tab, no
|
|
103
|
+
* status bar, nothing to copy.
|
|
104
|
+
*/
|
|
105
|
+
test("a folded destination is still a real link", async () => {
|
|
106
|
+
const { body } = await mountStory(AllStates)
|
|
107
|
+
|
|
108
|
+
const panel = expectAgentDrivable(body, {
|
|
109
|
+
name: "Main menu",
|
|
110
|
+
role: "dialog",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const archives = expectAgentDrivable(within(panel), {
|
|
114
|
+
name: "Archives",
|
|
115
|
+
role: "link",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await expect(archives).toHaveAttribute(
|
|
119
|
+
"href",
|
|
120
|
+
"/archives",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// The complete list, in the product's order — a folded nav is not
|
|
124
|
+
// an abridged one.
|
|
125
|
+
await expect(
|
|
126
|
+
within(panel)
|
|
127
|
+
.getAllByRole("link")
|
|
128
|
+
.map((link) => link.textContent),
|
|
129
|
+
).toEqual([
|
|
130
|
+
"Triage",
|
|
131
|
+
"Backlog",
|
|
132
|
+
"Phases",
|
|
133
|
+
"Lanes",
|
|
134
|
+
"Board",
|
|
135
|
+
"Archives",
|
|
136
|
+
"Tonight",
|
|
137
|
+
"Chats",
|
|
138
|
+
"Settings",
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The fleet's habit is to render the nav twice and hide one copy
|
|
144
|
+
* with `hidden md:flex` — so every link is in the DOM at every
|
|
145
|
+
* width, and an agent driving the page finds two of each with no way
|
|
146
|
+
* to tell which one a human can see.
|
|
147
|
+
*/
|
|
148
|
+
test("a destination is mounted exactly once", async () => {
|
|
149
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
150
|
+
|
|
151
|
+
const nav = expectAgentDrivable(canvas, {
|
|
152
|
+
name: "Main, folded and open",
|
|
153
|
+
role: "navigation",
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await expect(
|
|
157
|
+
within(nav).queryAllByRole("link"),
|
|
158
|
+
).toHaveLength(0)
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(
|
|
162
|
+
within(
|
|
163
|
+
body.getByRole("dialog", { name: "Main menu" }),
|
|
164
|
+
).getAllByRole("link", { name: "Chats" }),
|
|
165
|
+
).toHaveLength(1)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A highlight is invisible to a screen reader and to anyone who
|
|
171
|
+
* cannot separate the two surfaces, so the current destination says
|
|
172
|
+
* so in the platform's own words — and exactly one does.
|
|
173
|
+
*/
|
|
174
|
+
test("the current destination is marked, and only it", async () => {
|
|
175
|
+
const { canvas } = await mountStory(AllStates)
|
|
176
|
+
|
|
177
|
+
const nav = expectAgentDrivable(canvas, {
|
|
178
|
+
name: "Main, current in bar",
|
|
179
|
+
role: "navigation",
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
within(nav)
|
|
184
|
+
.getAllByRole("link")
|
|
185
|
+
.filter(
|
|
186
|
+
(link) =>
|
|
187
|
+
link.getAttribute("aria-current") === "page",
|
|
188
|
+
)
|
|
189
|
+
.map((link) => link.textContent),
|
|
190
|
+
).toEqual(["Board"])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `/tasks/41` belongs to no destination, and the honest answer is
|
|
195
|
+
* that none is current. react-router's `<NavLink to="/">` would mark
|
|
196
|
+
* the root here unless the caller remembered `end`.
|
|
197
|
+
*/
|
|
198
|
+
test("a route no destination owns marks nothing", async () => {
|
|
199
|
+
const { canvas } = await mountStory(AllStates)
|
|
200
|
+
|
|
201
|
+
const nav = expectAgentDrivable(canvas, {
|
|
202
|
+
name: "Main, nothing current",
|
|
203
|
+
role: "navigation",
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
within(nav)
|
|
208
|
+
.getAllByRole("link")
|
|
209
|
+
.filter((link) => link.hasAttribute("aria-current")),
|
|
210
|
+
).toHaveLength(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A fold control belongs at an edge. `menuAlign="end"` puts it
|
|
215
|
+
* beside the header's own actions instead of hugging the wordmark
|
|
216
|
+
* with the whole track empty after it — and it reaches only the
|
|
217
|
+
* folded row, so the links' own position is not a prop.
|
|
218
|
+
*/
|
|
219
|
+
test("the folded trigger can sit at the end of the track", async () => {
|
|
220
|
+
const { canvas } = await mountStory(AllVariants)
|
|
221
|
+
|
|
222
|
+
const aligned = expectAgentDrivable(canvas, {
|
|
223
|
+
name: "Main, trigger at the end",
|
|
224
|
+
role: "navigation",
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const start = expectAgentDrivable(canvas, {
|
|
228
|
+
name: "Main, narrow",
|
|
229
|
+
role: "navigation",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await waitFor(() => {
|
|
233
|
+
expect(
|
|
234
|
+
within(aligned)
|
|
235
|
+
.getByRole("button", { name: "Main menu" })
|
|
236
|
+
.getBoundingClientRect().left,
|
|
237
|
+
).toBeGreaterThan(
|
|
238
|
+
within(start)
|
|
239
|
+
.getByRole("button", { name: "Main menu" })
|
|
240
|
+
.getBoundingClientRect().left,
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The trigger advertises what it opens, and gets focus back when it
|
|
247
|
+
* closes — both from `useRole` and `FloatingFocusManager` inside
|
|
248
|
+
* `Popover`, so neither can be forgotten here.
|
|
249
|
+
*/
|
|
250
|
+
test("the trigger advertises the panel it opens", async () => {
|
|
251
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
252
|
+
|
|
253
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
254
|
+
name: "Main menu",
|
|
255
|
+
role: "button",
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await expect(trigger).toHaveAttribute(
|
|
259
|
+
"aria-expanded",
|
|
260
|
+
"false",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
await expect(trigger).toHaveAttribute(
|
|
264
|
+
"aria-haspopup",
|
|
265
|
+
"dialog",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
await userEvent.click(trigger)
|
|
269
|
+
|
|
270
|
+
await expect(trigger).toHaveAttribute(
|
|
271
|
+
"aria-expanded",
|
|
272
|
+
"true",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const panel = expectAgentDrivable(body, {
|
|
276
|
+
name: "Main menu",
|
|
277
|
+
role: "dialog",
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(panel).toContainElement(
|
|
282
|
+
document.activeElement as HTMLElement,
|
|
283
|
+
)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await userEvent.keyboard("{Escape}")
|
|
287
|
+
|
|
288
|
+
await waitFor(() => {
|
|
289
|
+
expect(trigger).toHaveFocus()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Choosing a destination closes the menu behind you — the one
|
|
295
|
+
* interaction people read as "it didn't work" when it is missing.
|
|
296
|
+
*/
|
|
297
|
+
test("following a link in the panel closes it", async () => {
|
|
298
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
299
|
+
|
|
300
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
301
|
+
name: "Main menu",
|
|
302
|
+
role: "button",
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await userEvent.click(trigger)
|
|
306
|
+
|
|
307
|
+
const panel = expectAgentDrivable(body, {
|
|
308
|
+
name: "Main menu",
|
|
309
|
+
role: "dialog",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
await userEvent.click(
|
|
313
|
+
within(panel).getByRole("link", { name: "Phases" }),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
await waitFor(() => {
|
|
317
|
+
expect(
|
|
318
|
+
body.queryByRole("dialog", { name: "Main menu" }),
|
|
319
|
+
).toBeNull()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test("the folded nav audits clean while open", async () => {
|
|
324
|
+
const { canvasElement } = await mountStory(AllStates)
|
|
325
|
+
|
|
326
|
+
await expectNoAxeViolations(canvasElement)
|
|
327
|
+
|
|
328
|
+
// The panel is portalled out of the canvas, so the resting audit
|
|
329
|
+
// above cannot see it — an overflow audited only while shut is an
|
|
330
|
+
// overflow nobody audited.
|
|
331
|
+
await expectNoAxeViolations(document.body)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Where it actually goes. `Header`'s row is what sizes the bar; the
|
|
336
|
+
* bar measures that box to decide what to draw in it.
|
|
337
|
+
*/
|
|
338
|
+
test("the shell mounts exactly one nav", async () => {
|
|
339
|
+
const { canvas } = await mountStory(InAppShell)
|
|
340
|
+
|
|
341
|
+
await expect(
|
|
342
|
+
canvas.getAllByRole("navigation", { name: "Main" }),
|
|
343
|
+
).toHaveLength(1)
|
|
344
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
3
|
+
import type { Placement } from "@floating-ui/react"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
8
|
+
import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
|
|
9
|
+
import { Popover } from "../Popover/Popover.tsx"
|
|
10
|
+
import { useToolbarOverflow } from "../Toolbar/useToolbarOverflow.ts"
|
|
11
|
+
import { toClassName } from "../toClassName.ts"
|
|
12
|
+
import { NavBarLink } from "./NavBarLink.tsx"
|
|
13
|
+
import { getIsCurrentHref } from "./navBarCurrent.ts"
|
|
14
|
+
|
|
15
|
+
export type NavBarItem = {
|
|
16
|
+
/**
|
|
17
|
+
* Where it goes. Routed through the injected `RouterLink` when it
|
|
18
|
+
* is this app's own path, and left to the platform when it is not.
|
|
19
|
+
*/
|
|
20
|
+
href: string
|
|
21
|
+
/** Decoration beside the label. Never the accessible name. */
|
|
22
|
+
icon?: ReactNode
|
|
23
|
+
/** Defaults to `href`, which is already unique in a nav. */
|
|
24
|
+
key?: string
|
|
25
|
+
/** The visible text, and the accessible name. */
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type NavBarProps = {
|
|
30
|
+
className?: string
|
|
31
|
+
/**
|
|
32
|
+
* Where the reader is — `useLocation().pathname` in a
|
|
33
|
+
* react-router app. `aria-current="page"` is derived from it by
|
|
34
|
+
* `getIsCurrentHref`, which matches a parent path for its
|
|
35
|
+
* children and treats `/` as exact.
|
|
36
|
+
*
|
|
37
|
+
* Omitted means nothing is current, which is the honest answer on
|
|
38
|
+
* a route no destination owns.
|
|
39
|
+
*/
|
|
40
|
+
currentHref?: string
|
|
41
|
+
/**
|
|
42
|
+
* **Initial** only, and the bar owns it from then on — the same
|
|
43
|
+
* uncontrolled contract every other component here has. It exists
|
|
44
|
+
* so a story (and a screenshot) can show the menu open; an app
|
|
45
|
+
* almost never passes it.
|
|
46
|
+
*/
|
|
47
|
+
isMenuVisible?: boolean
|
|
48
|
+
items: readonly NavBarItem[]
|
|
49
|
+
/**
|
|
50
|
+
* Required, and it becomes the `<nav>`'s `aria-label`. Two unnamed
|
|
51
|
+
* navigation landmarks in one page is axe's `landmark-unique`, and
|
|
52
|
+
* more to the point it is a screen reader announcing "navigation"
|
|
53
|
+
* twice with no way to tell which is the main one.
|
|
54
|
+
*/
|
|
55
|
+
label: string
|
|
56
|
+
/**
|
|
57
|
+
* Where the trigger sits in the bar's track once the row has
|
|
58
|
+
* folded. `"end"` when the header puts its own actions at the
|
|
59
|
+
* far edge, so the fold control joins them instead of hugging
|
|
60
|
+
* the wordmark with the whole track empty after it.
|
|
61
|
+
*
|
|
62
|
+
* It reaches **only** the folded row, which holds exactly one
|
|
63
|
+
* item — so it cannot disturb where the links sit when the bar
|
|
64
|
+
* is whole, and there is deliberately no prop that can.
|
|
65
|
+
*/
|
|
66
|
+
menuAlign?: "end" | "start"
|
|
67
|
+
/**
|
|
68
|
+
* The collapsed trigger's glyph. **No default** — the library
|
|
69
|
+
* ships no icons, and `☰` renders as nothing where the font lacks
|
|
70
|
+
* it. With no icon the trigger is a text button reading
|
|
71
|
+
* `menuLabel`, which works everywhere.
|
|
72
|
+
*/
|
|
73
|
+
menuIcon?: ReactNode
|
|
74
|
+
/**
|
|
75
|
+
* Names the trigger and, through it, the panel it opens. A
|
|
76
|
+
* `role="dialog"` takes no accessible name from its content.
|
|
77
|
+
*/
|
|
78
|
+
menuLabel?: string
|
|
79
|
+
menuPlacement?: Placement
|
|
80
|
+
size?: ControlSize
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The app's destinations as real links, in one line — folding into a
|
|
85
|
+
* single menu button, whole, when that line does not fit.
|
|
86
|
+
*
|
|
87
|
+
* ## Why this is not `Toolbar`
|
|
88
|
+
*
|
|
89
|
+
* `Toolbar` already solves collapse-into-an-overflow, and this
|
|
90
|
+
* component **reuses its measurement** (`useToolbarOverflow`) rather
|
|
91
|
+
* than growing a second one. What it cannot reuse is the rendering:
|
|
92
|
+
* a `ToolbarAction` is `{ label, onSelect }` and a collapsed one
|
|
93
|
+
* becomes a `MenuItem`, so a nav folded into a `Toolbar` is a row of
|
|
94
|
+
* `<button>`s. Navigation is an `<a href>` — see `NavBarLink`.
|
|
95
|
+
*
|
|
96
|
+
* The overflow is a `Popover` (`role="dialog"`) rather than a `Menu`
|
|
97
|
+
* for the same reason: `role="menu"` permits only the `menuitem`
|
|
98
|
+
* family, and a `menuitem` is something you *do*, not somewhere you
|
|
99
|
+
* *go*.
|
|
100
|
+
*
|
|
101
|
+
* ## It folds WHOLE, and that is the decision
|
|
102
|
+
*
|
|
103
|
+
* `Toolbar` collapses **progressively** — items drop off the end one
|
|
104
|
+
* at a time — because its items are ranked: the two things you do to
|
|
105
|
+
* a running job matter more than the two you do to the deck. A nav
|
|
106
|
+
* is not ranked. Its order is the shape of the product (Docket's
|
|
107
|
+
* reads as the pipeline: Triage → Backlog → Phases → Board →
|
|
108
|
+
* Archives), and a half-collapsed one splits that shape across two
|
|
109
|
+
* places with no rule the reader can learn — "some of the app is up
|
|
110
|
+
* here and the rest is behind a button" is worse than either whole
|
|
111
|
+
* answer. So the bar shows **every** destination or **none**, and
|
|
112
|
+
* the menu, when it opens, is the complete list in the product's own
|
|
113
|
+
* order.
|
|
114
|
+
*
|
|
115
|
+
* ## Measured, not breakpointed
|
|
116
|
+
*
|
|
117
|
+
* There is no `collapseAt` prop and no media query. The bar reads
|
|
118
|
+
* the width its links actually have and folds when their sum stops
|
|
119
|
+
* fitting, so a longer label, a tenth destination, a
|
|
120
|
+
* `data-density="kiosk"` or a reader at 175% browser zoom each move
|
|
121
|
+
* the fold on their own. A hardcoded pixel number is wrong the first
|
|
122
|
+
* time any of those changes — mux-magic collapses at `480px` and
|
|
123
|
+
* plex-channels at `760px`, and neither number has a reason.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ **The bar must not be sized by its own contents.** It measures
|
|
126
|
+
* its own box to decide what to draw in it, so a container that
|
|
127
|
+
* shrink-wraps makes the answer its own input: put it in a
|
|
128
|
+
* `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row. The root
|
|
129
|
+
* carries `min-w-0 flex-1` for the flex case; a grid track is the
|
|
130
|
+
* caller's to declare. Same trap `useAdaptiveColumns` documents from
|
|
131
|
+
* the other end.
|
|
132
|
+
*
|
|
133
|
+
* ## Mounted exactly once
|
|
134
|
+
*
|
|
135
|
+
* A destination is in the bar or in the panel, never both. The
|
|
136
|
+
* fleet's habit is to render the whole nav twice and hide one copy
|
|
137
|
+
* with `hidden md:flex` — mux-magic's `PageHeader` across two
|
|
138
|
+
* ~55-line blocks, mail-sifter's `TriageQueue` again — which puts
|
|
139
|
+
* every link in the DOM at every width, so an agent driving the page
|
|
140
|
+
* finds two of each and cannot tell which one a human can see.
|
|
141
|
+
*/
|
|
142
|
+
export const NavBar = ({
|
|
143
|
+
className,
|
|
144
|
+
currentHref,
|
|
145
|
+
isMenuVisible = false,
|
|
146
|
+
items,
|
|
147
|
+
label,
|
|
148
|
+
menuAlign = "start",
|
|
149
|
+
menuIcon,
|
|
150
|
+
menuLabel = "Menu",
|
|
151
|
+
menuPlacement = "bottom-start",
|
|
152
|
+
size = "sm",
|
|
153
|
+
}: NavBarProps): ReactNode => {
|
|
154
|
+
const itemKeys = items.map(
|
|
155
|
+
(item) => item.key ?? item.href,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const { containerRef, trackItem, visibleCount } =
|
|
159
|
+
useToolbarOverflow({ itemKeys })
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* All or nothing. `visibleCount` is the progressive answer and
|
|
163
|
+
* this is the only thing read off it: the moment one link stops
|
|
164
|
+
* fitting, every link goes into the menu together.
|
|
165
|
+
*
|
|
166
|
+
* The hook's `trackTrigger` is deliberately not wired up, and the
|
|
167
|
+
* missing measurement costs nothing here. It exists so a
|
|
168
|
+
* progressive bar can ask "how many fit **beside a trigger**",
|
|
169
|
+
* which is a question this bar never asks: either every link fits
|
|
170
|
+
* with no trigger at all, or the row is one trigger and nothing
|
|
171
|
+
* else. `chooseVisibleCount` tests the whole-row case first and
|
|
172
|
+
* with the trigger costed at zero, so that branch is exact.
|
|
173
|
+
*/
|
|
174
|
+
const isCollapsed = visibleCount < items.length
|
|
175
|
+
|
|
176
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
177
|
+
isVisible: isMenuVisible,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const panelItemSize = usePanelItemSize("lg")
|
|
181
|
+
|
|
182
|
+
const renderLink = (
|
|
183
|
+
item: NavBarItem,
|
|
184
|
+
placement: "bar" | "panel",
|
|
185
|
+
) => (
|
|
186
|
+
<NavBarLink
|
|
187
|
+
isCurrent={getIsCurrentHref({
|
|
188
|
+
currentHref,
|
|
189
|
+
href: item.href,
|
|
190
|
+
})}
|
|
191
|
+
item={item}
|
|
192
|
+
key={item.key ?? item.href}
|
|
193
|
+
onNavigate={placement === "panel" ? hide : undefined}
|
|
194
|
+
placement={placement}
|
|
195
|
+
size={placement === "bar" ? size : panelItemSize}
|
|
196
|
+
trackElement={
|
|
197
|
+
placement === "bar" ? trackItem : undefined
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const trigger =
|
|
203
|
+
menuIcon === undefined ? (
|
|
204
|
+
<Button
|
|
205
|
+
appearance="ghost"
|
|
206
|
+
intent="neutral"
|
|
207
|
+
onClick={toggle}
|
|
208
|
+
size={size}
|
|
209
|
+
>
|
|
210
|
+
{menuLabel}
|
|
211
|
+
</Button>
|
|
212
|
+
) : (
|
|
213
|
+
<IconButton
|
|
214
|
+
appearance="ghost"
|
|
215
|
+
intent="neutral"
|
|
216
|
+
label={menuLabel}
|
|
217
|
+
onClick={toggle}
|
|
218
|
+
size={size}
|
|
219
|
+
>
|
|
220
|
+
{menuIcon}
|
|
221
|
+
</IconButton>
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<nav
|
|
226
|
+
aria-label={label}
|
|
227
|
+
className={toClassName(
|
|
228
|
+
"flex min-w-0 flex-1 items-center gap-1",
|
|
229
|
+
// Two written-out entries rather than one interpolated
|
|
230
|
+
// string: Tailwind's scanner cannot see a class that
|
|
231
|
+
// exists only at runtime.
|
|
232
|
+
isCollapsed && menuAlign === "end"
|
|
233
|
+
? "justify-end"
|
|
234
|
+
: "justify-start",
|
|
235
|
+
className,
|
|
236
|
+
)}
|
|
237
|
+
ref={containerRef}
|
|
238
|
+
>
|
|
239
|
+
{isCollapsed ? (
|
|
240
|
+
<Popover
|
|
241
|
+
// The default panel is `max-w-xs p-3` — sized for a
|
|
242
|
+
// paragraph of prose. This one holds full-bleed rows that
|
|
243
|
+
// bring their own inset, so the padding shrinks to the
|
|
244
|
+
// gutter around them and the width is left to the list.
|
|
245
|
+
className="max-w-none p-1"
|
|
246
|
+
heading={menuLabel}
|
|
247
|
+
isVisible={isVisible}
|
|
248
|
+
onDismiss={hide}
|
|
249
|
+
placement={menuPlacement}
|
|
250
|
+
trigger={trigger}
|
|
251
|
+
>
|
|
252
|
+
{/*
|
|
253
|
+
A list, so a screen reader says how many destinations
|
|
254
|
+
there are before the reader starts down them — the one
|
|
255
|
+
thing the bar's own row communicates by simply being
|
|
256
|
+
visible all at once.
|
|
257
|
+
*/}
|
|
258
|
+
<ul className="flex min-w-48 list-none flex-col gap-0.5">
|
|
259
|
+
{items.map((item) => (
|
|
260
|
+
<li key={item.key ?? item.href}>
|
|
261
|
+
{renderLink(item, "panel")}
|
|
262
|
+
</li>
|
|
263
|
+
))}
|
|
264
|
+
</ul>
|
|
265
|
+
</Popover>
|
|
266
|
+
) : (
|
|
267
|
+
items.map((item) => renderLink(item, "bar"))
|
|
268
|
+
)}
|
|
269
|
+
</nav>
|
|
270
|
+
)
|
|
271
|
+
}
|