@charcuterie/ui 3.23.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.
Files changed (62) hide show
  1. package/dist/Board/Board.d.ts +6 -3
  2. package/dist/Board/Board.d.ts.map +1 -1
  3. package/dist/Board/Board.js +19 -1
  4. package/dist/Board/Board.js.map +1 -1
  5. package/dist/Board/BoardCard.d.ts +42 -7
  6. package/dist/Board/BoardCard.d.ts.map +1 -1
  7. package/dist/Board/BoardCard.js +54 -8
  8. package/dist/Board/BoardCard.js.map +1 -1
  9. package/dist/Nav/Nav.d.ts +167 -0
  10. package/dist/Nav/Nav.d.ts.map +1 -0
  11. package/dist/Nav/Nav.js +165 -0
  12. package/dist/Nav/Nav.js.map +1 -0
  13. package/dist/Nav/NavLink.d.ts +43 -0
  14. package/dist/Nav/NavLink.d.ts.map +1 -0
  15. package/dist/Nav/NavLink.js +111 -0
  16. package/dist/Nav/NavLink.js.map +1 -0
  17. package/dist/Nav/navItems.d.ts +67 -0
  18. package/dist/Nav/navItems.d.ts.map +1 -0
  19. package/dist/Nav/navItems.js +104 -0
  20. package/dist/Nav/navItems.js.map +1 -0
  21. package/dist/Nav/useNavLayout.d.ts +86 -0
  22. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  23. package/dist/Nav/useNavLayout.js +114 -0
  24. package/dist/Nav/useNavLayout.js.map +1 -0
  25. package/dist/NavBar/NavBar.d.ts +37 -111
  26. package/dist/NavBar/NavBar.d.ts.map +1 -1
  27. package/dist/NavBar/NavBar.js +30 -110
  28. package/dist/NavBar/NavBar.js.map +1 -1
  29. package/dist/index.d.ts +6 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Board/Board.mdx +25 -2
  35. package/src/Board/Board.stories.tsx +52 -1
  36. package/src/Board/Board.test.tsx +82 -0
  37. package/src/Board/Board.tsx +24 -4
  38. package/src/Board/BoardCard.tsx +103 -21
  39. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  40. package/src/Nav/Nav.mdx +169 -0
  41. package/src/Nav/Nav.stories.tsx +402 -0
  42. package/src/Nav/Nav.test.tsx +338 -0
  43. package/src/Nav/Nav.tsx +406 -0
  44. package/src/Nav/NavLink.tsx +220 -0
  45. package/src/Nav/navItems.test.ts +163 -0
  46. package/src/Nav/navItems.ts +175 -0
  47. package/src/Nav/useNavLayout.ts +197 -0
  48. package/src/NavBar/NavBar.mdx +26 -0
  49. package/src/NavBar/NavBar.tsx +54 -240
  50. package/src/index.ts +20 -1
  51. package/src/sourceRules.test.ts +13 -1
  52. package/dist/NavBar/NavBarLink.d.ts +0 -43
  53. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  54. package/dist/NavBar/NavBarLink.js +0 -67
  55. package/dist/NavBar/NavBarLink.js.map +0 -1
  56. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  57. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  58. package/dist/NavBar/navBarCurrent.js +0 -48
  59. package/dist/NavBar/navBarCurrent.js.map +0 -1
  60. package/src/NavBar/NavBarLink.tsx +0 -139
  61. package/src/NavBar/navBarCurrent.test.ts +0 -81
  62. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -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
+ }