@charcuterie/ui 3.20.0 → 3.22.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 (43) hide show
  1. package/README.md +11 -0
  2. package/dist/Board/BoardCard.d.ts +42 -18
  3. package/dist/Board/BoardCard.d.ts.map +1 -1
  4. package/dist/Board/BoardCard.js +17 -1
  5. package/dist/Board/BoardCard.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/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/Board/Board.stories.tsx +100 -0
  28. package/src/Board/Board.test.tsx +62 -0
  29. package/src/Board/BoardCard.tsx +71 -18
  30. package/src/DropRail/DropRail.mdx +90 -0
  31. package/src/DropRail/DropRail.stories.tsx +328 -0
  32. package/src/DropRail/DropRail.test.tsx +154 -0
  33. package/src/DropRail/DropRail.tsx +496 -0
  34. package/src/NavBar/NavBar.mdx +157 -0
  35. package/src/NavBar/NavBar.stories.tsx +350 -0
  36. package/src/NavBar/NavBar.test.tsx +344 -0
  37. package/src/NavBar/NavBar.tsx +271 -0
  38. package/src/NavBar/NavBarLink.tsx +139 -0
  39. package/src/NavBar/navBarCurrent.test.ts +81 -0
  40. package/src/NavBar/navBarCurrent.ts +64 -0
  41. package/src/icons.storyHelpers.tsx +12 -0
  42. package/src/index.ts +11 -0
  43. package/src/sourceRules.test.ts +24 -1
@@ -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
+ }
@@ -0,0 +1,139 @@
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 type { NavBarItem } from "./NavBar.tsx"
13
+
14
+ export type NavBarLinkProps = {
15
+ isCurrent: boolean
16
+ item: NavBarItem
17
+ /**
18
+ * Fires when the reader activates this link **in a way that keeps
19
+ * them on the page** — a plain left click, Enter, Space. It is
20
+ * what closes the collapsed panel behind them.
21
+ *
22
+ * A modified click (ctrl, meta, shift, alt) opens a new tab and
23
+ * leaves this document exactly where it was, so it deliberately
24
+ * does not fire: shutting the menu the reader is still working
25
+ * through is the behaviour that makes people stop ctrl-clicking.
26
+ */
27
+ onNavigate?: () => void
28
+ /** Where it is drawn. A bar row and a panel row paint differently. */
29
+ placement: "bar" | "panel"
30
+ size: ControlSize
31
+ /** Hands the bar row's box to the measuring hook. Bar only. */
32
+ trackElement?: (
33
+ key: string,
34
+ element: HTMLElement | null,
35
+ ) => void
36
+ }
37
+
38
+ /**
39
+ * One destination, drawn either in the bar or in the collapsed
40
+ * panel — and it is a real `<a href>` in both.
41
+ *
42
+ * That is the whole reason this component exists rather than the
43
+ * `Menu` a collapsed `Toolbar` opens: `MenuItem` is `{ label,
44
+ * onSelect }`, so a nav folded into one becomes a row of
45
+ * `<button>`s. It looks identical in a screenshot and it loses
46
+ * middle-click, ctrl-click, "open in a new tab", "copy link
47
+ * address", the status bar, and the browser's own visited styling.
48
+ * `BuildingAnApp.mdx` names `<button onClick={navigate}>` as the
49
+ * fleet's most common navigation defect; a collapse mechanism that
50
+ * introduced it at narrow widths would be that defect on a timer.
51
+ *
52
+ * The router seam is `ButtonLink`'s, unchanged: an external
53
+ * destination or a fragment falls back to a plain anchor rather than
54
+ * being pushed onto a history stack that has no route for it.
55
+ */
56
+ export const NavBarLink = ({
57
+ isCurrent,
58
+ item,
59
+ onNavigate,
60
+ placement,
61
+ size,
62
+ trackElement,
63
+ }: NavBarLinkProps): ReactNode => {
64
+ const RouterLink = useRouterLink()
65
+
66
+ const LinkElement = getIsRoutedHref(item.href)
67
+ ? RouterLink
68
+ : "a"
69
+
70
+ const className =
71
+ placement === "bar"
72
+ ? toClassName(
73
+ "inline-flex shrink-0 items-center rounded-md border border-transparent whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
74
+ CONTROL_SIZE_CLASS[size],
75
+ // Two written-out entries rather than one interpolated
76
+ // string, the same rule `SegmentedOption` follows:
77
+ // Tailwind's scanner cannot see a class that exists only
78
+ // at runtime, and `tailwindCandidates.test.ts` rejects a
79
+ // template literal in a className outright.
80
+ isCurrent
81
+ ? "bg-intent-neutral-surface font-medium text-content-primary"
82
+ : "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
83
+ FOCUS_RING_CLASS,
84
+ )
85
+ : toClassName(
86
+ "flex w-full items-center rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
87
+ PANEL_ITEM_SIZE_CLASS[size],
88
+ // `-hover`, not the plain surface: the panel is drawn on
89
+ // `surface-overlay`, which every dark scheme paints
90
+ // *lighter* than the base tint, so the plain one reads as
91
+ // no change at all. Same correction `MenuAction` carries.
92
+ isCurrent
93
+ ? "bg-intent-neutral-surface-hover font-medium text-content-primary"
94
+ : "text-content-primary hover:bg-intent-neutral-surface-hover",
95
+ FOCUS_RING_CLASS,
96
+ )
97
+
98
+ return (
99
+ <LinkElement
100
+ // The platform's own "you are here", and the reason the paint
101
+ // above is never the only signal: a highlight is invisible to
102
+ // a screen reader and to anyone who cannot tell the two
103
+ // surfaces apart.
104
+ aria-current={isCurrent ? "page" : undefined}
105
+ className={className}
106
+ href={item.href}
107
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
108
+ if (
109
+ event.defaultPrevented ||
110
+ event.button !== 0 ||
111
+ event.altKey ||
112
+ event.ctrlKey ||
113
+ event.metaKey ||
114
+ event.shiftKey
115
+ ) {
116
+ return
117
+ }
118
+
119
+ onNavigate?.()
120
+ }}
121
+ ref={(element: HTMLAnchorElement | null) => {
122
+ trackElement?.(item.key ?? item.href, element)
123
+ }}
124
+ >
125
+ {item.icon === undefined ? null : (
126
+ // Decoration beside the item's own text. An icon that
127
+ // announces itself turns "Settings" into "gear Settings".
128
+ <span
129
+ aria-hidden="true"
130
+ className="shrink-0 text-content-secondary"
131
+ >
132
+ {item.icon}
133
+ </span>
134
+ )}
135
+
136
+ {item.label}
137
+ </LinkElement>
138
+ )
139
+ }
@@ -0,0 +1,81 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { getIsCurrentHref } from "./navBarCurrent.ts"
4
+
5
+ const isCurrent = (currentHref: string, href: string) =>
6
+ getIsCurrentHref({ currentHref, href })
7
+
8
+ test("a destination is current on its own path", () => {
9
+ expect(isCurrent("/backlog", "/backlog")).toBe(true)
10
+
11
+ expect(isCurrent("/backlog", "/board")).toBe(false)
12
+ })
13
+
14
+ /**
15
+ * A nav item names a **section**, and a section does not stop being
16
+ * the one you are in when you open something inside it.
17
+ */
18
+ test("a parent path is current for its children", () => {
19
+ expect(isCurrent("/tasks/41", "/tasks")).toBe(true)
20
+
21
+ expect(isCurrent("/tasks/41/edit", "/tasks")).toBe(true)
22
+ })
23
+
24
+ /**
25
+ * The match is on whole segments. A bare `startsWith` marks `/task`
26
+ * as current for `/tasks`, which is a highlight on the wrong item
27
+ * and nothing to see in a review.
28
+ */
29
+ test("a partial segment is not a match", () => {
30
+ expect(isCurrent("/tasks", "/task")).toBe(false)
31
+
32
+ expect(isCurrent("/backlogged", "/backlog")).toBe(false)
33
+ })
34
+
35
+ /**
36
+ * react-router's `<NavLink to="/">` is active on every route unless
37
+ * the caller remembers `end` — the footgun behind every "why is Home
38
+ * always highlighted" bug. The root matches only itself here, and
39
+ * there is no prop to change it.
40
+ */
41
+ test("the root is exact", () => {
42
+ expect(isCurrent("/", "/")).toBe(true)
43
+
44
+ expect(isCurrent("/backlog", "/")).toBe(false)
45
+ })
46
+
47
+ /**
48
+ * Sorting a list does not move the reader to a different
49
+ * destination.
50
+ */
51
+ test("the query string and the fragment are not part of the answer", () => {
52
+ expect(
53
+ isCurrent("/backlog?focus=search", "/backlog"),
54
+ ).toBe(true)
55
+
56
+ expect(isCurrent("/backlog#top", "/backlog")).toBe(true)
57
+
58
+ expect(
59
+ isCurrent("/backlog", "/backlog?focus=search"),
60
+ ).toBe(true)
61
+ })
62
+
63
+ test("a trailing slash is the same place", () => {
64
+ expect(isCurrent("/backlog/", "/backlog")).toBe(true)
65
+
66
+ expect(isCurrent("/backlog", "/backlog/")).toBe(true)
67
+ })
68
+
69
+ /**
70
+ * `undefined` is what a caller passes when it does not know, and the
71
+ * honest reading of that is that nothing is current — not that
72
+ * everything is.
73
+ */
74
+ test("no current path marks nothing", () => {
75
+ expect(
76
+ getIsCurrentHref({
77
+ currentHref: undefined,
78
+ href: "/backlog",
79
+ }),
80
+ ).toBe(false)
81
+ })
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which destination the reader is on — the rule behind
3
+ * `aria-current="page"`, kept pure so it can be checked in Node.
4
+ *
5
+ * The library is router-agnostic (see `RouterLinkProvider`), so it
6
+ * cannot ask a router which link is active. The app hands it one
7
+ * string — `useLocation().pathname`, `window.location.pathname`,
8
+ * whatever it has — and this decides the rest.
9
+ *
10
+ * ## A parent path is current for its children
11
+ *
12
+ * `/tasks` is current while the reader is on `/tasks/41`, because a
13
+ * nav item names a **section** and a section does not stop being the
14
+ * one you are in when you open something inside it. The match is on
15
+ * whole segments: `/task` is *not* current for `/tasks`, which a bare
16
+ * `startsWith` would get wrong.
17
+ *
18
+ * ## `/` is exact, and that is deliberately not react-router
19
+ *
20
+ * react-router's `<NavLink to="/">` is active on **every** route
21
+ * unless the caller remembers `end`, which is the footgun behind
22
+ * every "why is Home always highlighted" bug in the fleet. The root
23
+ * matches only itself here, and there is no prop to change it: a nav
24
+ * with two current items is a nav that has stopped saying anything.
25
+ *
26
+ * ## The query string and the fragment are not part of the answer
27
+ *
28
+ * `/backlog?focus=search` is still the Backlog. Sorting a list does
29
+ * not move the reader to a different destination.
30
+ */
31
+
32
+ const toComparablePath = (href: string): string => {
33
+ const path = href.split(/[#?]/u)[0] ?? ""
34
+
35
+ // A trailing slash is the same place, so it must not change the
36
+ // answer — but `/` itself has to survive being trimmed.
37
+ return path.length > 1 ? path.replace(/\/+$/u, "") : path
38
+ }
39
+
40
+ export const getIsCurrentHref = ({
41
+ currentHref,
42
+ href,
43
+ }: {
44
+ /** Where the reader is. `undefined` means "nothing is current". */
45
+ currentHref: string | undefined
46
+ /** Where this destination goes. */
47
+ href: string
48
+ }): boolean => {
49
+ if (currentHref === undefined) {
50
+ return false
51
+ }
52
+
53
+ const current = toComparablePath(currentHref)
54
+
55
+ const target = toComparablePath(href)
56
+
57
+ if (target === "" || target === "/") {
58
+ return current === target
59
+ }
60
+
61
+ return (
62
+ current === target || current.startsWith(`${target}/`)
63
+ )
64
+ }
@@ -192,6 +192,18 @@ export const MoreIcon = (): ReactNode => (
192
192
  </svg>
193
193
  )
194
194
 
195
+ /**
196
+ * The three-line "open the nav" glyph, for `NavBar`'s collapsed
197
+ * trigger. U+2630 TRIGRAM FOR HEAVEN — the character a repo reaches
198
+ * for instead — measures blank in this sandbox's chromium, exactly
199
+ * like the three-dot glyph above.
200
+ */
201
+ export const MenuIcon = (): ReactNode => (
202
+ <svg {...iconProps} aria-hidden="true">
203
+ <path d="M4 6h16M4 12h16M4 18h16" />
204
+ </svg>
205
+ )
206
+
195
207
  /**
196
208
  * The markdown toolbar's nine, plus the bar's own overflow above.
197
209
  *
package/src/index.ts CHANGED
@@ -190,6 +190,11 @@ export type {
190
190
  DialogSize,
191
191
  } from "./Dialog/Dialog.tsx"
192
192
  export { Dialog } from "./Dialog/Dialog.tsx"
193
+ export type {
194
+ DropRailProps,
195
+ DropRailTarget,
196
+ } from "./DropRail/DropRail.tsx"
197
+ export { DropRail } from "./DropRail/DropRail.tsx"
193
198
  export type {
194
199
  EmptyStateProps,
195
200
  EmptyStateSize,
@@ -271,6 +276,12 @@ export type {
271
276
  export { Menu } from "./Menu/Menu.tsx"
272
277
  export type { ModalProps } from "./Modal/Modal.tsx"
273
278
  export { Modal } from "./Modal/Modal.tsx"
279
+ export type {
280
+ NavBarItem,
281
+ NavBarProps,
282
+ } from "./NavBar/NavBar.tsx"
283
+ export { NavBar } from "./NavBar/NavBar.tsx"
284
+ export { getIsCurrentHref } from "./NavBar/navBarCurrent.ts"
274
285
  export type {
275
286
  PickerOption,
276
287
  PickerProps,
@@ -489,7 +489,30 @@ test("the barrel is the only place components are re-exported", async () => {
489
489
  // It is also why `safeUrls.ts` moved up out of
490
490
  // `markdownEditorCodeMirror/`: two surfaces taking a URL out of a
491
491
  // document and putting it in an `href` is one allowlist, not two.
492
- expect(componentNames.length).toBe(56)
492
+ //
493
+ // `DropRail` — every move destination at once, as a listbox that a
494
+ // pointer, a tap or the arrow keys all drive — is +1 -> 57. It has
495
+ // no members, and it takes no drag-and-drop dependency: the
496
+ // hit test is `elementFromPoint` over its own chips, which is the
497
+ // only geometric question a rail has. `Board` keeps its `Menu`,
498
+ // which is the right control at three lanes; this is the one for a
499
+ // page with thirty-four groups on it.
500
+ //
501
+ // `NavBar` — the app's destinations as real links, folding whole
502
+ // into one menu button when the row stops fitting — is +1 -> 58.
503
+ // It **reuses** `Toolbar`'s `useToolbarOverflow` rather than
504
+ // forking the measurement, and exists as its own component for one
505
+ // reason the type system makes plain: a `ToolbarAction` is
506
+ // `{ label, onSelect }` and a collapsed one becomes a `MenuItem`,
507
+ // which is also `{ label, onSelect }` — so a nav folded into a
508
+ // `Toolbar` is a row of `<button>`s, which is the fleet's most
509
+ // common navigation defect and screenshots identically to the
510
+ // correct thing. `NavBarLink.tsx` is its member and stays out of
511
+ // this count by the `<Name>/<Name>.tsx` rule; `navBarCurrent.ts`
512
+ // is too, while still being exported from the barrel — an app that
513
+ // marks a destination current anywhere else must ask the same
514
+ // question the same way.
515
+ expect(componentNames.length).toBe(58)
493
516
 
494
517
  for (const name of componentNames) {
495
518
  expect(barrel).toContain(`export { ${name} }`)