@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,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
+ }