@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/Nav/Nav.tsx
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
3
|
+
import type { Placement } from "@floating-ui/react"
|
|
4
|
+
import type { ReactElement, 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 { NavLink } from "./NavLink.tsx"
|
|
13
|
+
import type { NavItem } from "./navItems.ts"
|
|
14
|
+
import {
|
|
15
|
+
getNavItemKey,
|
|
16
|
+
resolveActiveKey,
|
|
17
|
+
} from "./navItems.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where the destinations are drawn. Not *whether* — see
|
|
21
|
+
* `useNavLayout`, which answers that from the width.
|
|
22
|
+
*/
|
|
23
|
+
export type NavLayout =
|
|
24
|
+
| "bar"
|
|
25
|
+
| "bottom"
|
|
26
|
+
| "menu"
|
|
27
|
+
| "rail"
|
|
28
|
+
| "railIcons"
|
|
29
|
+
|
|
30
|
+
type NavCommonProps = {
|
|
31
|
+
/**
|
|
32
|
+
* The destinations, in the order the product reads.
|
|
33
|
+
*
|
|
34
|
+
* Annotate the array as `NavRailItem[]` when the app uses a rail:
|
|
35
|
+
* it is the same shape with `icon` made **required**, because
|
|
36
|
+
* `railIcons` takes the label away and an item with no glyph is a
|
|
37
|
+
* blank square that still navigates. `NavItem` keeps `icon`
|
|
38
|
+
* optional so that a `bar`, where the label never goes anywhere,
|
|
39
|
+
* does not have to invent one.
|
|
40
|
+
*/
|
|
41
|
+
items: readonly NavItem[]
|
|
42
|
+
/**
|
|
43
|
+
* The address the app is at — usually `location.pathname`. The
|
|
44
|
+
* item that matches wears `aria-current="page"`.
|
|
45
|
+
*
|
|
46
|
+
* Omitted means nothing is current, which is honest on a screen
|
|
47
|
+
* the nav does not contain (a modal route, an error page) and is
|
|
48
|
+
* better than marking the closest thing.
|
|
49
|
+
*/
|
|
50
|
+
activeHref?: string
|
|
51
|
+
className?: string
|
|
52
|
+
/**
|
|
53
|
+
* The `<nav>`'s accessible name. Default `"Main"`.
|
|
54
|
+
*
|
|
55
|
+
* Required in spirit and defaulted in practice: two unnamed
|
|
56
|
+
* navigations on one page are two landmarks a screen reader
|
|
57
|
+
* announces identically, which is axe's `landmark-unique`. An app
|
|
58
|
+
* with a second nav — a queue's sub-navigation, a footer — names
|
|
59
|
+
* that one and leaves this one alone.
|
|
60
|
+
*/
|
|
61
|
+
label?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type NavPlacedProps = NavCommonProps & {
|
|
65
|
+
/**
|
|
66
|
+
* **Initial** only, and the bar owns it from then on — the same
|
|
67
|
+
* uncontrolled contract every other component here has. It exists
|
|
68
|
+
* so a story (and a screenshot) can show the menu open; an app
|
|
69
|
+
* almost never passes it. `bar` only.
|
|
70
|
+
*/
|
|
71
|
+
isMenuVisible?: boolean
|
|
72
|
+
layout?: "bar" | "bottom" | "rail" | "railIcons"
|
|
73
|
+
/**
|
|
74
|
+
* Where the trigger sits in the bar's track once the row has
|
|
75
|
+
* folded. `"end"` when the header puts its own actions at the far
|
|
76
|
+
* edge, so the fold control joins them instead of hugging the
|
|
77
|
+
* wordmark with the whole track empty after it.
|
|
78
|
+
*
|
|
79
|
+
* It reaches **only** the folded row, which holds exactly one
|
|
80
|
+
* item — so it cannot disturb where the links sit when the bar is
|
|
81
|
+
* whole, and there is deliberately no prop that can.
|
|
82
|
+
*/
|
|
83
|
+
menuAlign?: "end" | "start"
|
|
84
|
+
/**
|
|
85
|
+
* The folded trigger's glyph. **No default** — the library ships
|
|
86
|
+
* no icons, and a hamburger renders as nothing where the font
|
|
87
|
+
* lacks it. With no icon the trigger is a text button reading
|
|
88
|
+
* `menuLabel`, which works everywhere.
|
|
89
|
+
*/
|
|
90
|
+
menuIcon?: ReactNode
|
|
91
|
+
/**
|
|
92
|
+
* Names the trigger and, through it, the panel it opens. A
|
|
93
|
+
* `role="dialog"` takes no accessible name from its content.
|
|
94
|
+
*/
|
|
95
|
+
menuLabel?: string
|
|
96
|
+
menuPlacement?: Placement
|
|
97
|
+
size?: ControlSize
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type NavMenuProps = NavCommonProps & {
|
|
101
|
+
isVisible: boolean
|
|
102
|
+
layout: "menu"
|
|
103
|
+
onDismiss: () => void
|
|
104
|
+
placement?: Placement
|
|
105
|
+
/** The control the panel hangs off. **Cloned, not wrapped.** */
|
|
106
|
+
trigger: ReactElement
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The union splits on `menu` and **only** on `menu`, because that is
|
|
111
|
+
* the one layout an app cannot reach with a variable: its trigger
|
|
112
|
+
* goes in the `Header`, so the app is already writing the branch.
|
|
113
|
+
*
|
|
114
|
+
* Everything else takes `layout={navLayout.layout}` straight from
|
|
115
|
+
* `useNavLayout` — which is a *variable*, not a literal, so a union
|
|
116
|
+
* that also split `items` by layout would force a `switch` at the
|
|
117
|
+
* one call site this component exists to make short.
|
|
118
|
+
*/
|
|
119
|
+
export type NavProps = NavMenuProps | NavPlacedProps
|
|
120
|
+
|
|
121
|
+
const LIST_CLASS: Record<NavLayout, string> = {
|
|
122
|
+
bar: "flex min-w-0 flex-row items-center gap-1",
|
|
123
|
+
// `auto-cols-fr` with `grid-flow-col`: equal columns whose count
|
|
124
|
+
// comes from the item list, so four destinations divide the width
|
|
125
|
+
// and five still do, with no per-app count to keep in step.
|
|
126
|
+
bottom: "grid auto-cols-fr grid-flow-col gap-0",
|
|
127
|
+
menu: "flex min-w-48 flex-col gap-0.5",
|
|
128
|
+
rail: "flex min-w-0 flex-col gap-1",
|
|
129
|
+
railIcons: "flex flex-col items-center gap-1",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const NAV_CLASS: Record<NavLayout, string> = {
|
|
133
|
+
bar: "flex min-w-0 flex-1 items-center gap-1",
|
|
134
|
+
bottom: "border-border-subtle border-t bg-surface-raised",
|
|
135
|
+
menu: "min-w-0",
|
|
136
|
+
rail: "min-w-0",
|
|
137
|
+
railIcons: "min-w-0",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The app's destinations — a header row, a side rail, a collapsed
|
|
142
|
+
* rail, a strip along the foot of the Narrow View, or a panel behind
|
|
143
|
+
* one control. The same list, drawn where it fits.
|
|
144
|
+
*
|
|
145
|
+
* ## What was actually missing
|
|
146
|
+
*
|
|
147
|
+
* `Shell` / `Header` / `Rail` / `Main` shipped the page frame on
|
|
148
|
+
* 2026-08-10 and left the **contents of the nav rail empty**. Twelve
|
|
149
|
+
* UI repos, and exactly two of them had a `<nav>` at all: Docket
|
|
150
|
+
* hand-rolled a row of `NavLink`s with the collapse rule in the
|
|
151
|
+
* app's own stylesheet, Folio hand-rolled another, and the other ten
|
|
152
|
+
* had no destinations anywhere. The two that existed already
|
|
153
|
+
* disagreed — Docket *wraps* below its breakpoint and stands three
|
|
154
|
+
* lines tall on a phone, which is not a decision anybody made, it is
|
|
155
|
+
* what `flex-wrap` does when nobody writes the other rule.
|
|
156
|
+
*
|
|
157
|
+
* ## Every item is a real link
|
|
158
|
+
*
|
|
159
|
+
* `<a href>`, through the injected `RouterLinkProvider` — see
|
|
160
|
+
* `NavLink`, which is where that argument is written down.
|
|
161
|
+
*
|
|
162
|
+
* ## `aria-current="page"`, and exactly one of them
|
|
163
|
+
*
|
|
164
|
+
* `resolveActiveKey` takes the **deepest** matching path, so an app
|
|
165
|
+
* holding both `/settings` and `/settings/labels` marks the page the
|
|
166
|
+
* reader is actually on rather than both.
|
|
167
|
+
*
|
|
168
|
+
* ## `bar` folds WHOLE, and it is measured
|
|
169
|
+
*
|
|
170
|
+
* There is no `collapseAt` and no media query on this axis. The bar
|
|
171
|
+
* reads the width its links actually have and folds when their sum
|
|
172
|
+
* stops fitting, so a longer label, a tenth destination, a
|
|
173
|
+
* `data-density="kiosk"` or a reader at 175% browser zoom each move
|
|
174
|
+
* the fold on their own. mux-magic collapses at a hardcoded `480px`
|
|
175
|
+
* and plex-channels at `760px`, and neither number has a reason.
|
|
176
|
+
*
|
|
177
|
+
* It folds *whole* rather than progressively, unlike `Toolbar`:
|
|
178
|
+
* `Toolbar`'s items are ranked, and a nav's order is the shape of
|
|
179
|
+
* the product (Docket's reads as the pipeline: Triage → Backlog →
|
|
180
|
+
* Phases → Board → Archives). Half of that behind a button is a rule
|
|
181
|
+
* no reader can learn.
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ **A `bar` must not be sized by its own contents.** It measures
|
|
184
|
+
* its own box to decide what to draw in it, so a container that
|
|
185
|
+
* shrink-wraps makes the answer its own input: put it in a
|
|
186
|
+
* `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row. The root
|
|
187
|
+
* carries `min-w-0 flex-1` for the flex case; a grid track is the
|
|
188
|
+
* caller's to declare.
|
|
189
|
+
*
|
|
190
|
+
* ## `menu` is the rail's narrow state, and the app owns it
|
|
191
|
+
*
|
|
192
|
+
* `bar` builds its own trigger because it knows when it folded.
|
|
193
|
+
* `menu` takes one, because the rail's narrow state puts that
|
|
194
|
+
* control in the `Header` — a different corner of `Shell`'s grid
|
|
195
|
+
* from a side rail, and no media query moves an element across a
|
|
196
|
+
* grid. `useNavLayout` is what tells the app which of the two it is
|
|
197
|
+
* rendering.
|
|
198
|
+
*
|
|
199
|
+
* ## Mounted exactly once
|
|
200
|
+
*
|
|
201
|
+
* A destination is in the bar or in the panel, never both. The
|
|
202
|
+
* fleet's habit is to render the whole nav twice and hide one copy
|
|
203
|
+
* with `hidden md:flex` — mux-magic's `PageHeader` across two
|
|
204
|
+
* ~55-line blocks, mail-sifter's `TriageQueue` again — which puts
|
|
205
|
+
* every link in the DOM at every width, so an agent driving the page
|
|
206
|
+
* finds two of each and cannot tell which one a human can see.
|
|
207
|
+
*/
|
|
208
|
+
export const Nav = (props: NavProps): ReactNode => {
|
|
209
|
+
const {
|
|
210
|
+
activeHref,
|
|
211
|
+
className,
|
|
212
|
+
items,
|
|
213
|
+
label = "Main",
|
|
214
|
+
} = props
|
|
215
|
+
|
|
216
|
+
const layout = props.layout ?? "bar"
|
|
217
|
+
|
|
218
|
+
const activeKey = resolveActiveKey(items, activeHref)
|
|
219
|
+
|
|
220
|
+
const itemKeys = items.map(getNavItemKey)
|
|
221
|
+
|
|
222
|
+
const { containerRef, trackItem, visibleCount } =
|
|
223
|
+
useToolbarOverflow({ itemKeys })
|
|
224
|
+
|
|
225
|
+
// The bar-only knobs. `menu` is the one variant that does not
|
|
226
|
+
// carry them, and it is the one variant the app branches on
|
|
227
|
+
// anyway.
|
|
228
|
+
const placedProps = props.layout === "menu" ? null : props
|
|
229
|
+
|
|
230
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
231
|
+
isVisible: placedProps?.isMenuVisible ?? false,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const panelItemSize = usePanelItemSize("lg")
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* All or nothing. `visibleCount` is the progressive answer and
|
|
238
|
+
* this is the only thing read off it: the moment one link stops
|
|
239
|
+
* fitting, every link goes into the menu together.
|
|
240
|
+
*/
|
|
241
|
+
const isFolded =
|
|
242
|
+
layout === "bar" && visibleCount < items.length
|
|
243
|
+
|
|
244
|
+
const size = placedProps?.size ?? "sm"
|
|
245
|
+
|
|
246
|
+
const renderList = (
|
|
247
|
+
placement:
|
|
248
|
+
| "bar"
|
|
249
|
+
| "bottom"
|
|
250
|
+
| "panel"
|
|
251
|
+
| "rail"
|
|
252
|
+
| "railIcons",
|
|
253
|
+
listLayout: NavLayout,
|
|
254
|
+
) => (
|
|
255
|
+
<ul
|
|
256
|
+
className={toClassName(
|
|
257
|
+
"list-none p-0",
|
|
258
|
+
LIST_CLASS[listLayout],
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{items.map((item) => {
|
|
262
|
+
const key = getNavItemKey(item)
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<li className="min-w-0" key={key}>
|
|
266
|
+
<NavLink
|
|
267
|
+
isCurrent={key === activeKey}
|
|
268
|
+
item={item}
|
|
269
|
+
onNavigate={
|
|
270
|
+
placement === "panel" ? hide : undefined
|
|
271
|
+
}
|
|
272
|
+
placement={placement}
|
|
273
|
+
size={
|
|
274
|
+
placement === "panel" ? panelItemSize : size
|
|
275
|
+
}
|
|
276
|
+
trackElement={
|
|
277
|
+
placement === "bar"
|
|
278
|
+
? (element) => {
|
|
279
|
+
trackItem(key, element)
|
|
280
|
+
}
|
|
281
|
+
: undefined
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
</li>
|
|
285
|
+
)
|
|
286
|
+
})}
|
|
287
|
+
</ul>
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if (props.layout === "menu") {
|
|
291
|
+
/**
|
|
292
|
+
* A `Popover` rather than a `Menu`. `Menu`'s items are
|
|
293
|
+
* `onSelect` callbacks on `<button>`s, which is right for the
|
|
294
|
+
* things a menu usually holds — undo, redo, a scheme cycle — and
|
|
295
|
+
* wrong for a destination, for every reason `NavLink` gives.
|
|
296
|
+
*
|
|
297
|
+
* `modal={false}` inside it, so the caret is not trapped: the
|
|
298
|
+
* panel is a disclosure, not a dialog somebody has to answer.
|
|
299
|
+
*/
|
|
300
|
+
return (
|
|
301
|
+
<Popover
|
|
302
|
+
// The default panel is `max-w-xs p-3` — sized for a
|
|
303
|
+
// paragraph of prose. This one holds full-bleed rows that
|
|
304
|
+
// bring their own inset, so the padding shrinks to the
|
|
305
|
+
// gutter around them and the width is left to the list.
|
|
306
|
+
className="max-w-none p-1"
|
|
307
|
+
heading={label}
|
|
308
|
+
isVisible={props.isVisible}
|
|
309
|
+
onDismiss={props.onDismiss}
|
|
310
|
+
placement={props.placement ?? "bottom-start"}
|
|
311
|
+
trigger={props.trigger}
|
|
312
|
+
>
|
|
313
|
+
{/*
|
|
314
|
+
A list, so a screen reader says how many destinations
|
|
315
|
+
there are before the reader starts down them — the one
|
|
316
|
+
thing a visible row communicates by simply being visible
|
|
317
|
+
all at once.
|
|
318
|
+
*/}
|
|
319
|
+
<nav aria-label={label}>
|
|
320
|
+
{renderList("panel", "menu")}
|
|
321
|
+
</nav>
|
|
322
|
+
</Popover>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (layout !== "bar") {
|
|
327
|
+
return (
|
|
328
|
+
<nav
|
|
329
|
+
aria-label={label}
|
|
330
|
+
className={toClassName(
|
|
331
|
+
NAV_CLASS[layout],
|
|
332
|
+
className,
|
|
333
|
+
)}
|
|
334
|
+
>
|
|
335
|
+
{renderList(
|
|
336
|
+
layout === "rail"
|
|
337
|
+
? "rail"
|
|
338
|
+
: layout === "railIcons"
|
|
339
|
+
? "railIcons"
|
|
340
|
+
: "bottom",
|
|
341
|
+
layout,
|
|
342
|
+
)}
|
|
343
|
+
</nav>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const menuLabel = placedProps?.menuLabel ?? "Menu"
|
|
348
|
+
|
|
349
|
+
const menuIcon = placedProps?.menuIcon
|
|
350
|
+
|
|
351
|
+
const trigger =
|
|
352
|
+
menuIcon === undefined ? (
|
|
353
|
+
<Button
|
|
354
|
+
appearance="ghost"
|
|
355
|
+
intent="neutral"
|
|
356
|
+
onClick={toggle}
|
|
357
|
+
size={size}
|
|
358
|
+
>
|
|
359
|
+
{menuLabel}
|
|
360
|
+
</Button>
|
|
361
|
+
) : (
|
|
362
|
+
<IconButton
|
|
363
|
+
appearance="ghost"
|
|
364
|
+
intent="neutral"
|
|
365
|
+
label={menuLabel}
|
|
366
|
+
onClick={toggle}
|
|
367
|
+
size={size}
|
|
368
|
+
>
|
|
369
|
+
{menuIcon}
|
|
370
|
+
</IconButton>
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<nav
|
|
375
|
+
aria-label={label}
|
|
376
|
+
className={toClassName(
|
|
377
|
+
NAV_CLASS.bar,
|
|
378
|
+
// Two written-out entries rather than one interpolated
|
|
379
|
+
// string: Tailwind's scanner cannot see a class that exists
|
|
380
|
+
// only at runtime.
|
|
381
|
+
isFolded && placedProps?.menuAlign === "end"
|
|
382
|
+
? "justify-end"
|
|
383
|
+
: "justify-start",
|
|
384
|
+
className,
|
|
385
|
+
)}
|
|
386
|
+
ref={containerRef}
|
|
387
|
+
>
|
|
388
|
+
{isFolded ? (
|
|
389
|
+
<Popover
|
|
390
|
+
className="max-w-none p-1"
|
|
391
|
+
heading={menuLabel}
|
|
392
|
+
isVisible={isVisible}
|
|
393
|
+
onDismiss={hide}
|
|
394
|
+
placement={
|
|
395
|
+
placedProps?.menuPlacement ?? "bottom-start"
|
|
396
|
+
}
|
|
397
|
+
trigger={trigger}
|
|
398
|
+
>
|
|
399
|
+
{renderList("panel", "menu")}
|
|
400
|
+
</Popover>
|
|
401
|
+
) : (
|
|
402
|
+
renderList("bar", "bar")
|
|
403
|
+
)}
|
|
404
|
+
</nav>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { MouseEvent, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CONTROL_SIZE_CLASS,
|
|
6
|
+
PANEL_ITEM_SIZE_CLASS,
|
|
7
|
+
} from "../controlStyles.ts"
|
|
8
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
9
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
10
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
11
|
+
import { toClassName } from "../toClassName.ts"
|
|
12
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
13
|
+
import type { NavItem } from "./navItems.ts"
|
|
14
|
+
|
|
15
|
+
/** Where the row is drawn. Each paints differently; all are links. */
|
|
16
|
+
export type NavLinkPlacement =
|
|
17
|
+
| "bar"
|
|
18
|
+
| "bottom"
|
|
19
|
+
| "panel"
|
|
20
|
+
| "rail"
|
|
21
|
+
| "railIcons"
|
|
22
|
+
|
|
23
|
+
export type NavLinkProps = {
|
|
24
|
+
isCurrent: boolean
|
|
25
|
+
item: NavItem
|
|
26
|
+
/**
|
|
27
|
+
* Fires when the reader activates this link **in a way that keeps
|
|
28
|
+
* them on the page** — a plain left click, Enter, Space. It is
|
|
29
|
+
* what closes the panel behind them.
|
|
30
|
+
*
|
|
31
|
+
* A modified click (ctrl, meta, shift, alt) opens a new tab and
|
|
32
|
+
* leaves this document exactly where it was, so it deliberately
|
|
33
|
+
* does not fire: shutting the menu the reader is still working
|
|
34
|
+
* through is the behaviour that makes people stop ctrl-clicking.
|
|
35
|
+
*/
|
|
36
|
+
onNavigate?: () => void
|
|
37
|
+
placement: NavLinkPlacement
|
|
38
|
+
size: ControlSize
|
|
39
|
+
/** Hands the bar row's box to the measuring hook. Bar only. */
|
|
40
|
+
trackElement?: (element: HTMLElement | null) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PLACEMENT_CLASS: Record<NavLinkPlacement, string> = {
|
|
44
|
+
bar: "inline-flex shrink-0 items-center rounded-md border border-transparent whitespace-nowrap",
|
|
45
|
+
// A foot-of-the-screen target is a thumb target, so it takes the
|
|
46
|
+
// 44px WCAG 2.5.5 floor rather than a control height. It is a
|
|
47
|
+
// genuinely standalone tap target and shares a row with nothing,
|
|
48
|
+
// which is the case
|
|
49
|
+
// `2026-08-05-controls-share-one-height-no-per-component-touch-floor`
|
|
50
|
+
// reserves `MIN_TOUCH_TARGET_CLASS` for.
|
|
51
|
+
bottom:
|
|
52
|
+
"flex min-h-(--control-min-touch-target) flex-col items-center justify-center gap-1 rounded-none px-1 py-2 text-xs",
|
|
53
|
+
panel: "flex w-full items-center rounded-sm text-start",
|
|
54
|
+
rail: "flex w-full items-center rounded-md",
|
|
55
|
+
// Square, and sized on both axes from the height token — the same
|
|
56
|
+
// rule `ICON_CONTROL_SIZE_CLASS` states, so a collapsed rail item
|
|
57
|
+
// and an `IconButton` beside it measure the same.
|
|
58
|
+
railIcons:
|
|
59
|
+
"flex size-(--control-height-lg) items-center justify-center rounded-md",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `-hover` in the panel, not the plain surface: the panel is drawn
|
|
64
|
+
* on `surface-overlay`, which every dark scheme paints *lighter*
|
|
65
|
+
* than the base tint, so the plain one reads as no change at all.
|
|
66
|
+
* Same correction `MenuAction` carries.
|
|
67
|
+
*/
|
|
68
|
+
const CURRENT_CLASS: Record<NavLinkPlacement, string> = {
|
|
69
|
+
bar: "bg-intent-neutral-surface font-medium text-content-primary",
|
|
70
|
+
bottom:
|
|
71
|
+
"bg-intent-neutral-surface font-medium text-content-primary",
|
|
72
|
+
panel:
|
|
73
|
+
"bg-intent-neutral-surface-hover font-medium text-content-primary",
|
|
74
|
+
rail: "bg-intent-neutral-surface font-medium text-content-primary",
|
|
75
|
+
railIcons:
|
|
76
|
+
"bg-intent-neutral-surface font-medium text-content-primary",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const RESTING_CLASS: Record<NavLinkPlacement, string> = {
|
|
80
|
+
bar: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
81
|
+
bottom:
|
|
82
|
+
"text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
83
|
+
panel:
|
|
84
|
+
"text-content-primary hover:bg-intent-neutral-surface-hover",
|
|
85
|
+
rail: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
86
|
+
railIcons:
|
|
87
|
+
"text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One destination — and it is a real `<a href>` in every placement.
|
|
92
|
+
*
|
|
93
|
+
* That is the whole reason this exists rather than the `Menu` a
|
|
94
|
+
* collapsed `Toolbar` opens: `MenuItem` is `{ label, onSelect }`, so
|
|
95
|
+
* a nav folded into one becomes a row of `<button>`s. It looks
|
|
96
|
+
* identical in a screenshot and it loses middle-click, ctrl-click,
|
|
97
|
+
* "open in a new tab", "copy link address", the status bar, and the
|
|
98
|
+
* browser's own visited styling. `BuildingAnApp.mdx` names
|
|
99
|
+
* `<button onClick={navigate}>` as the fleet's most common
|
|
100
|
+
* navigation defect; a collapse mechanism that introduced it at
|
|
101
|
+
* narrow widths would be that defect on a timer.
|
|
102
|
+
*
|
|
103
|
+
* The router seam is `ButtonLink`'s, unchanged: an external
|
|
104
|
+
* destination or a fragment falls back to a plain anchor rather than
|
|
105
|
+
* being pushed onto a history stack that has no route for it.
|
|
106
|
+
*/
|
|
107
|
+
export const NavLink = ({
|
|
108
|
+
isCurrent,
|
|
109
|
+
item,
|
|
110
|
+
onNavigate,
|
|
111
|
+
placement,
|
|
112
|
+
size,
|
|
113
|
+
trackElement,
|
|
114
|
+
}: NavLinkProps): ReactNode => {
|
|
115
|
+
const RouterLink = useRouterLink()
|
|
116
|
+
|
|
117
|
+
const isExternal = item.isExternal === true
|
|
118
|
+
|
|
119
|
+
const LinkElement =
|
|
120
|
+
isExternal || !getIsRoutedHref(item.href)
|
|
121
|
+
? "a"
|
|
122
|
+
: RouterLink
|
|
123
|
+
|
|
124
|
+
const isIconOnly = placement === "railIcons"
|
|
125
|
+
|
|
126
|
+
const sizeClass =
|
|
127
|
+
placement === "panel"
|
|
128
|
+
? PANEL_ITEM_SIZE_CLASS[size]
|
|
129
|
+
: placement === "bar"
|
|
130
|
+
? CONTROL_SIZE_CLASS[size]
|
|
131
|
+
: placement === "rail"
|
|
132
|
+
? "min-h-(--control-height-md) gap-(--control-gap-md) px-(--control-padding-inline-md) py-1.5 text-md"
|
|
133
|
+
: ""
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<LinkElement
|
|
137
|
+
// The platform's own "you are here", and the reason the paint
|
|
138
|
+
// is never the only signal: a highlight is invisible to a
|
|
139
|
+
// screen reader and to anyone who cannot tell the two surfaces
|
|
140
|
+
// apart. A `font-weight` swap alone is the same WCAG 1.4.1
|
|
141
|
+
// failure.
|
|
142
|
+
aria-current={isCurrent ? "page" : undefined}
|
|
143
|
+
className={toClassName(
|
|
144
|
+
"no-underline transition-colors duration-(--duration-fast) ease-standard",
|
|
145
|
+
PLACEMENT_CLASS[placement],
|
|
146
|
+
sizeClass,
|
|
147
|
+
// Two written-out entries rather than one interpolated
|
|
148
|
+
// string, the same rule `SegmentedOption` follows:
|
|
149
|
+
// Tailwind's scanner cannot see a class that exists only at
|
|
150
|
+
// runtime, and `tailwindCandidates.test.ts` rejects a
|
|
151
|
+
// template literal in a className outright.
|
|
152
|
+
isCurrent
|
|
153
|
+
? CURRENT_CLASS[placement]
|
|
154
|
+
: RESTING_CLASS[placement],
|
|
155
|
+
FOCUS_RING_CLASS,
|
|
156
|
+
)}
|
|
157
|
+
href={item.href}
|
|
158
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
159
|
+
if (
|
|
160
|
+
event.defaultPrevented ||
|
|
161
|
+
event.button !== 0 ||
|
|
162
|
+
event.altKey ||
|
|
163
|
+
event.ctrlKey ||
|
|
164
|
+
event.metaKey ||
|
|
165
|
+
event.shiftKey
|
|
166
|
+
) {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onNavigate?.()
|
|
171
|
+
}}
|
|
172
|
+
ref={(element: HTMLAnchorElement | null) => {
|
|
173
|
+
trackElement?.(element)
|
|
174
|
+
}}
|
|
175
|
+
// `noreferrer` beside `noopener` on purpose: one closes the
|
|
176
|
+
// `window.opener` hole, the other stops this app's address
|
|
177
|
+
// leaking as the referrer to a product it does not own.
|
|
178
|
+
rel={isExternal ? "noopener noreferrer" : undefined}
|
|
179
|
+
target={isExternal ? "_blank" : undefined}
|
|
180
|
+
// A pointer can recover the name a collapsed rail has hidden.
|
|
181
|
+
// It is not the accessible name — see the `VisuallyHidden`
|
|
182
|
+
// below — because `title` alone is not reliably announced.
|
|
183
|
+
title={isIconOnly ? item.label : undefined}
|
|
184
|
+
>
|
|
185
|
+
{item.icon === undefined ? null : (
|
|
186
|
+
// Decoration beside the item's own text. An icon that
|
|
187
|
+
// announces itself turns "Settings" into "gear Settings".
|
|
188
|
+
<span
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
className="flex shrink-0 text-content-secondary"
|
|
191
|
+
>
|
|
192
|
+
{item.icon}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{isIconOnly ? (
|
|
197
|
+
<VisuallyHidden>{item.label}</VisuallyHidden>
|
|
198
|
+
) : (
|
|
199
|
+
// `truncate`, not `min-w-0` alone: a flex item's automatic
|
|
200
|
+
// minimum is its content's min-content width, so one long
|
|
201
|
+
// unbroken destination name becomes the rail's floor and
|
|
202
|
+
// widens the column. The full text stays reachable in the
|
|
203
|
+
// `title` a truncated element gets from the platform.
|
|
204
|
+
<span
|
|
205
|
+
className={
|
|
206
|
+
placement === "bar" ? undefined : "truncate"
|
|
207
|
+
}
|
|
208
|
+
>
|
|
209
|
+
{item.label}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{isExternal ? (
|
|
214
|
+
<VisuallyHidden>
|
|
215
|
+
(opens in a new tab)
|
|
216
|
+
</VisuallyHidden>
|
|
217
|
+
) : null}
|
|
218
|
+
</LinkElement>
|
|
219
|
+
)
|
|
220
|
+
}
|