@charcuterie/ui 3.23.0 → 3.25.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 (68) hide show
  1. package/README.md +18 -0
  2. package/dist/Board/Board.d.ts +6 -3
  3. package/dist/Board/Board.d.ts.map +1 -1
  4. package/dist/Board/Board.js +19 -1
  5. package/dist/Board/Board.js.map +1 -1
  6. package/dist/Board/BoardCard.d.ts +42 -7
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +54 -8
  9. package/dist/Board/BoardCard.js.map +1 -1
  10. package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts +31 -0
  11. package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts.map +1 -0
  12. package/dist/DeploymentUpdate/useDeploymentUpdate.js +94 -0
  13. package/dist/DeploymentUpdate/useDeploymentUpdate.js.map +1 -0
  14. package/dist/Nav/Nav.d.ts +167 -0
  15. package/dist/Nav/Nav.d.ts.map +1 -0
  16. package/dist/Nav/Nav.js +165 -0
  17. package/dist/Nav/Nav.js.map +1 -0
  18. package/dist/Nav/NavLink.d.ts +43 -0
  19. package/dist/Nav/NavLink.d.ts.map +1 -0
  20. package/dist/Nav/NavLink.js +111 -0
  21. package/dist/Nav/NavLink.js.map +1 -0
  22. package/dist/Nav/navItems.d.ts +67 -0
  23. package/dist/Nav/navItems.d.ts.map +1 -0
  24. package/dist/Nav/navItems.js +104 -0
  25. package/dist/Nav/navItems.js.map +1 -0
  26. package/dist/Nav/useNavLayout.d.ts +86 -0
  27. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  28. package/dist/Nav/useNavLayout.js +114 -0
  29. package/dist/Nav/useNavLayout.js.map +1 -0
  30. package/dist/NavBar/NavBar.d.ts +37 -111
  31. package/dist/NavBar/NavBar.d.ts.map +1 -1
  32. package/dist/NavBar/NavBar.js +30 -110
  33. package/dist/NavBar/NavBar.js.map +1 -1
  34. package/dist/index.d.ts +8 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -1
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/Board/Board.mdx +25 -2
  40. package/src/Board/Board.stories.tsx +52 -1
  41. package/src/Board/Board.test.tsx +82 -0
  42. package/src/Board/Board.tsx +24 -4
  43. package/src/Board/BoardCard.tsx +103 -21
  44. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  45. package/src/DeploymentUpdate/useDeploymentUpdate.ts +143 -0
  46. package/src/Nav/Nav.mdx +169 -0
  47. package/src/Nav/Nav.stories.tsx +402 -0
  48. package/src/Nav/Nav.test.tsx +338 -0
  49. package/src/Nav/Nav.tsx +406 -0
  50. package/src/Nav/NavLink.tsx +220 -0
  51. package/src/Nav/navItems.test.ts +163 -0
  52. package/src/Nav/navItems.ts +175 -0
  53. package/src/Nav/useNavLayout.ts +197 -0
  54. package/src/NavBar/NavBar.mdx +26 -0
  55. package/src/NavBar/NavBar.tsx +54 -240
  56. package/src/index.ts +29 -1
  57. package/src/sourceRules.test.ts +13 -1
  58. package/dist/NavBar/NavBarLink.d.ts +0 -43
  59. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  60. package/dist/NavBar/NavBarLink.js +0 -67
  61. package/dist/NavBar/NavBarLink.js.map +0 -1
  62. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  63. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  64. package/dist/NavBar/navBarCurrent.js +0 -48
  65. package/dist/NavBar/navBarCurrent.js.map +0 -1
  66. package/src/NavBar/NavBarLink.tsx +0 -139
  67. package/src/NavBar/navBarCurrent.test.ts +0 -81
  68. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -0,0 +1,163 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import type { NavItem } from "./navItems.ts"
4
+ import {
5
+ getIsCurrentHref,
6
+ resolveActiveKey,
7
+ } from "./navItems.ts"
8
+
9
+ const isCurrent = (currentHref: string, href: string) =>
10
+ getIsCurrentHref({ currentHref, href })
11
+
12
+ const toItems = (...hrefs: string[]): NavItem[] =>
13
+ hrefs.map((href) => ({ href, label: href }))
14
+
15
+ test("a destination is current on its own path", () => {
16
+ expect(isCurrent("/backlog", "/backlog")).toBe(true)
17
+
18
+ expect(isCurrent("/backlog", "/board")).toBe(false)
19
+ })
20
+
21
+ test("a parent path is current for its children", () => {
22
+ expect(isCurrent("/tasks/41", "/tasks")).toBe(true)
23
+
24
+ expect(isCurrent("/tasks/41/edit", "/tasks")).toBe(true)
25
+ })
26
+
27
+ test("a partial segment is not a match", () => {
28
+ // The separator is part of the rule. A bare `startsWith` lights
29
+ // two unrelated destinations here.
30
+ expect(isCurrent("/tasks", "/task")).toBe(false)
31
+
32
+ expect(isCurrent("/backlogged", "/backlog")).toBe(false)
33
+
34
+ expect(isCurrent("/boardgames", "/board")).toBe(false)
35
+ })
36
+
37
+ test("the root is exact", () => {
38
+ expect(isCurrent("/", "/")).toBe(true)
39
+
40
+ // `/` prefixes every path in the app, so a permissive root would
41
+ // mark Home current on every screen.
42
+ expect(isCurrent("/backlog", "/")).toBe(false)
43
+ })
44
+
45
+ test("the query string and the fragment are not part of the answer", () => {
46
+ expect(
47
+ isCurrent("/backlog?focus=search", "/backlog"),
48
+ ).toBe(true)
49
+
50
+ expect(isCurrent("/backlog#top", "/backlog")).toBe(true)
51
+
52
+ expect(
53
+ isCurrent("/backlog", "/backlog?focus=search"),
54
+ ).toBe(true)
55
+ })
56
+
57
+ test("a trailing slash is the same place", () => {
58
+ expect(isCurrent("/backlog/", "/backlog")).toBe(true)
59
+
60
+ expect(isCurrent("/backlog", "/backlog/")).toBe(true)
61
+ })
62
+
63
+ test("no current path marks nothing", () => {
64
+ expect(
65
+ getIsCurrentHref({ currentHref: undefined, href: "/" }),
66
+ ).toBe(false)
67
+ })
68
+
69
+ test("resolveActiveKey answers the key, and defaults it to the href", () => {
70
+ expect(
71
+ resolveActiveKey(toItems("/", "/board"), "/board"),
72
+ ).toBe("/board")
73
+
74
+ expect(
75
+ resolveActiveKey(
76
+ [{ href: "/board", key: "board", label: "Board" }],
77
+ "/board",
78
+ ),
79
+ ).toBe("board")
80
+ })
81
+
82
+ test("no address means nothing is current", () => {
83
+ expect(resolveActiveKey(toItems("/", "/board"))).toBe(
84
+ null,
85
+ )
86
+ })
87
+
88
+ test("an address no destination owns marks nothing", () => {
89
+ expect(
90
+ resolveActiveKey(toItems("/", "/board"), "/nowhere"),
91
+ ).toBe(null)
92
+ })
93
+
94
+ /**
95
+ * The defect this function exists for. `getIsCurrentHref` is asked
96
+ * once per item and says `true` for both of these, which is two
97
+ * `aria-current="page"` in one nav — a nav that has stopped saying
98
+ * anything.
99
+ */
100
+ test("the deepest match wins, so exactly one item is current", () => {
101
+ expect(isCurrent("/settings/labels", "/settings")).toBe(
102
+ true,
103
+ )
104
+
105
+ expect(
106
+ resolveActiveKey(
107
+ toItems("/settings", "/settings/labels"),
108
+ "/settings/labels",
109
+ ),
110
+ ).toBe("/settings/labels")
111
+ })
112
+
113
+ test("the deepest match wins regardless of array order", () => {
114
+ // Longest by path, not by position: an app lists its destinations
115
+ // in the order the product reads, and that order must not decide
116
+ // which one lights up.
117
+ expect(
118
+ resolveActiveKey(
119
+ toItems("/settings/labels", "/settings"),
120
+ "/settings/labels",
121
+ ),
122
+ ).toBe("/settings/labels")
123
+ })
124
+
125
+ test("a parent is still current for a child it alone owns", () => {
126
+ expect(
127
+ resolveActiveKey(
128
+ toItems("/settings", "/settings/labels"),
129
+ "/settings/schemes",
130
+ ),
131
+ ).toBe("/settings")
132
+ })
133
+
134
+ test("an external destination is never current", () => {
135
+ // Without this, an external item pointing at another product's
136
+ // `/board` matches this app's own `/board`.
137
+ expect(
138
+ resolveActiveKey(
139
+ [
140
+ {
141
+ href: "https://example.com/board",
142
+ isExternal: true,
143
+ label: "Elsewhere",
144
+ },
145
+ { href: "/board", label: "Board" },
146
+ ],
147
+ "/board",
148
+ ),
149
+ ).toBe("/board")
150
+
151
+ expect(
152
+ resolveActiveKey(
153
+ [
154
+ {
155
+ href: "/board",
156
+ isExternal: true,
157
+ label: "Elsewhere",
158
+ },
159
+ ],
160
+ "/board",
161
+ ),
162
+ ).toBe(null)
163
+ })
@@ -0,0 +1,175 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export type NavItem = {
4
+ /**
5
+ * Where it goes. Routed through the injected `RouterLink` when it
6
+ * is this app's own path, and left to the platform when it is not.
7
+ */
8
+ href: string
9
+ /** Decoration beside the label. Never the accessible name. */
10
+ icon?: ReactNode
11
+ /**
12
+ * A destination outside this app. It renders a plain anchor with
13
+ * `target="_blank"` and `rel="noopener noreferrer"`, and it is
14
+ * never current — pushing `https://…` onto a router's history
15
+ * navigates the app to a route it does not have.
16
+ */
17
+ isExternal?: boolean
18
+ /** Defaults to `href`, which is already unique in a nav. */
19
+ key?: string
20
+ /** The visible text, and the accessible name. */
21
+ label: string
22
+ }
23
+
24
+ /**
25
+ * What the rail layouts take. `icon` stops being optional, because
26
+ * `railIcons` takes the **label** away: an item with no glyph is a
27
+ * blank square that still navigates, and there is no visual defect
28
+ * to notice because the row reads as padding.
29
+ *
30
+ * Optional would move that failure out of the type checker and into
31
+ * the first time somebody drags a window narrow. The library still
32
+ * ships no icons — a glyph in a default renders as nothing where the
33
+ * font lacks it, so the app brings its own.
34
+ */
35
+ export type NavRailItem = NavItem & {
36
+ icon: ReactNode
37
+ }
38
+
39
+ export const getNavItemKey = (item: NavItem): string =>
40
+ item.key ?? item.href
41
+
42
+ /**
43
+ * Which destination the reader is on — the rule behind
44
+ * `aria-current="page"`, kept pure so it can be checked in Node.
45
+ *
46
+ * The library is router-agnostic (see `RouterLinkProvider`), so it
47
+ * cannot ask a router which link is active. The app hands it one
48
+ * string — `useLocation().pathname`, `window.location.pathname`,
49
+ * whatever it has — and this decides the rest.
50
+ *
51
+ * ## A parent path is current for its children
52
+ *
53
+ * `/tasks` is current while the reader is on `/tasks/41`, because a
54
+ * nav item names a **section** and a section does not stop being the
55
+ * one you are in when you open something inside it. The match is on
56
+ * whole segments: `/task` is *not* current for `/tasks`, which a bare
57
+ * `startsWith` would get wrong.
58
+ *
59
+ * ## `/` is exact, and that is deliberately not react-router
60
+ *
61
+ * react-router's `<NavLink to="/">` is active on **every** route
62
+ * unless the caller remembers `end`, which is the footgun behind
63
+ * every "why is Home always highlighted" bug in the fleet. The root
64
+ * matches only itself here, and there is no prop to change it: a nav
65
+ * with two current items is a nav that has stopped saying anything.
66
+ *
67
+ * ## The query string and the fragment are not part of the answer
68
+ *
69
+ * `/backlog?focus=search` is still the Backlog. Sorting a list does
70
+ * not move the reader to a different destination.
71
+ *
72
+ * ⚠️ **This answers for ONE item, so it cannot keep the promise in
73
+ * the paragraph above by itself.** Given `/settings` and
74
+ * `/settings/labels`, it says `true` for both on the deeper path.
75
+ * `resolveActiveKey` is the whole-list answer, and it is what `Nav`
76
+ * uses; reach for this one only when there is a single link to test.
77
+ */
78
+ const toComparablePath = (href: string): string => {
79
+ const path = href.split(/[#?]/u)[0] ?? ""
80
+
81
+ // A trailing slash is the same place, so it must not change the
82
+ // answer — but `/` itself has to survive being trimmed.
83
+ return path.length > 1 ? path.replace(/\/+$/u, "") : path
84
+ }
85
+
86
+ export const getIsCurrentHref = ({
87
+ currentHref,
88
+ href,
89
+ }: {
90
+ /** Where the reader is. `undefined` means "nothing is current". */
91
+ currentHref: string | undefined
92
+ /** Where this destination goes. */
93
+ href: string
94
+ }): boolean => {
95
+ if (currentHref === undefined) {
96
+ return false
97
+ }
98
+
99
+ const current = toComparablePath(currentHref)
100
+
101
+ const target = toComparablePath(href)
102
+
103
+ if (target === "" || target === "/") {
104
+ return current === target
105
+ }
106
+
107
+ return (
108
+ current === target || current.startsWith(`${target}/`)
109
+ )
110
+ }
111
+
112
+ /**
113
+ * The current destination's key, or `null` — the whole-list answer,
114
+ * and the one `Nav` marks with `aria-current="page"`.
115
+ *
116
+ * ## Exactly one, because the deepest match wins
117
+ *
118
+ * `getIsCurrentHref` is asked per item, so a nav holding both
119
+ * `/settings` and `/settings/labels` gets `true` twice while the
120
+ * reader is on the deeper one. Two current items is a nav that has
121
+ * stopped saying anything — the same thing react-router's `<NavLink
122
+ * to="/">` footgun produces — so the longest matching path wins and
123
+ * the shallower section stands down.
124
+ *
125
+ * Longest by **path**, not by array position: an app must be able to
126
+ * list its destinations in the order the product reads (Docket's is
127
+ * the pipeline) without that order deciding which one lights up.
128
+ *
129
+ * ## An external item is never current
130
+ *
131
+ * It is not a place inside this app, so no address the app is at can
132
+ * be it. Without this, an item pointing at `https://example.com/board`
133
+ * matches the app's own `/board` on a plain string compare.
134
+ */
135
+ export const resolveActiveKey = (
136
+ items: readonly NavItem[],
137
+ activeHref?: string,
138
+ ): string | null => {
139
+ if (activeHref === undefined) {
140
+ return null
141
+ }
142
+
143
+ let matchedItem: NavItem | null = null
144
+
145
+ let matchedLength = -1
146
+
147
+ for (const item of items) {
148
+ if (item.isExternal === true) {
149
+ continue
150
+ }
151
+
152
+ if (
153
+ !getIsCurrentHref({
154
+ currentHref: activeHref,
155
+ href: item.href,
156
+ })
157
+ ) {
158
+ continue
159
+ }
160
+
161
+ const candidateLength = toComparablePath(
162
+ item.href,
163
+ ).length
164
+
165
+ if (candidateLength > matchedLength) {
166
+ matchedItem = item
167
+
168
+ matchedLength = candidateLength
169
+ }
170
+ }
171
+
172
+ return matchedItem === null
173
+ ? null
174
+ : getNavItemKey(matchedItem)
175
+ }
@@ -0,0 +1,197 @@
1
+ import { useMediaQuery } from "@charcuterie/logic"
2
+ import { matchMediaMatcher } from "@charcuterie/logic/browser"
3
+ import { useState } from "react"
4
+
5
+ /**
6
+ * What the reader asked for, which is not the same as what they get.
7
+ *
8
+ * `auto` lets the width decide. The other two are a deliberate
9
+ * override and outrank the width — but only where a rail fits at
10
+ * all; see `useNavLayout`.
11
+ */
12
+ export type NavLayoutMode =
13
+ | "auto"
14
+ | "collapsed"
15
+ | "expanded"
16
+
17
+ /** The three forms a rail-shaped nav actually takes on screen. */
18
+ export type ResolvedNavLayout =
19
+ | "menu"
20
+ | "rail"
21
+ | "railIcons"
22
+
23
+ export type UseNavLayoutOptions = {
24
+ /**
25
+ * Below this width the rail drops its labels. Default `64rem`,
26
+ * which is `screen.lg`.
27
+ */
28
+ iconsBelow?: string
29
+ /**
30
+ * Below this width there is no rail at all. Default `48rem`, which
31
+ * is `screen.md` — and the same step `Rail` itself collapses at,
32
+ * so the two cannot disagree.
33
+ */
34
+ menuBelow?: string
35
+ /**
36
+ * Where the manual choice is remembered. Omit and the override
37
+ * lasts until the tab is closed.
38
+ *
39
+ * Name it for the app (`"queuepilot-nav"`), not for this library:
40
+ * two apps on the same origin share `localStorage`, and this
41
+ * fleet's dev servers all sit on `localhost`.
42
+ */
43
+ storageKey?: string
44
+ }
45
+
46
+ export type NavLayoutState = {
47
+ /** `true` in `railIcons`. The rail's own collapse control reads it. */
48
+ isCollapsed: boolean
49
+ /** What to pass to `Nav`'s `layout`. */
50
+ layout: ResolvedNavLayout
51
+ mode: NavLayoutMode
52
+ setMode: (mode: NavLayoutMode) => void
53
+ /**
54
+ * Expanded ⇄ collapsed. From `auto` it goes to whichever of the two
55
+ * is **not** on screen now, so the first press always visibly does
56
+ * something — which "toggle" has to mean or the control reads as
57
+ * broken.
58
+ */
59
+ toggle: () => void
60
+ }
61
+
62
+ const DEFAULT_ICONS_BELOW = "64rem"
63
+
64
+ const DEFAULT_MENU_BELOW = "48rem"
65
+
66
+ const isNavLayoutMode = (
67
+ value: string | null,
68
+ ): value is NavLayoutMode =>
69
+ value === "auto" ||
70
+ value === "collapsed" ||
71
+ value === "expanded"
72
+
73
+ /**
74
+ * `localStorage`, wrapped both ways.
75
+ *
76
+ * It throws in a sandboxed iframe and in some privacy modes, and it
77
+ * does not exist at all while a page renders on a server — a rail
78
+ * that cannot remember its width should still have one.
79
+ *
80
+ * `@charcuterie/logic/browser`'s `localStoragePersistence` is the
81
+ * same shape and is deliberately **not** reused: it validates what
82
+ * it reads with `isColorSchemeMode`, so it answers `null` for every
83
+ * value this hook stores.
84
+ */
85
+ const readStoredMode = (
86
+ key: string,
87
+ ): NavLayoutMode | null => {
88
+ try {
89
+ const stored = window.localStorage.getItem(key)
90
+
91
+ return isNavLayoutMode(stored) ? stored : null
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ const writeStoredMode = (
98
+ key: string,
99
+ mode: NavLayoutMode,
100
+ ) => {
101
+ try {
102
+ window.localStorage.setItem(key, mode)
103
+ } catch {
104
+ // A page that cannot persist still switches for this session.
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The width rule for a rail-shaped navigation, written once.
110
+ *
111
+ * Three states, and the app chooses none of them:
112
+ *
113
+ * | Width | Layout | What the app renders |
114
+ * | --- | --- | --- |
115
+ * | `>= iconsBelow` | `rail` | a `Rail` holding a `Nav` |
116
+ * | `>= menuBelow` | `railIcons` | the same `Rail`, narrower |
117
+ * | below that | `menu` | a trigger in the `Header` |
118
+ *
119
+ * ### Why the app still places it
120
+ *
121
+ * Because the placement genuinely differs and the rule does not. The
122
+ * `menu` state puts a control in the header — a different corner of
123
+ * the page from a side rail — and no amount of restyling moves an
124
+ * element across `Shell`'s grid. So the library owns *which* state
125
+ * is right, at what width, with what override and where the override
126
+ * is remembered; the app owns two `if`s. That is the half that was
127
+ * being rewritten per app, and rewritten differently: Docket's nav
128
+ * wraps to three lines on a phone, mail-sifter collapses at `40rem`,
129
+ * and QueuePilot at `760px`, all of them arrived at alone.
130
+ *
131
+ * ### The override does not reach the narrow state
132
+ *
133
+ * `expanded` on a 390px screen would be a 212px rail beside 178px of
134
+ * content. The width wins there, and the remembered choice is *kept*
135
+ * rather than cleared — rotating a tablet back to landscape restores
136
+ * the rail the reader asked for, instead of silently forgetting it.
137
+ *
138
+ * ### It is a hook, not CSS
139
+ *
140
+ * The same reason `usePanelItemSize` is: a media query can restyle a
141
+ * rail, but it cannot move a control into the header, and it cannot
142
+ * tell the trigger whether to render at all. A resize re-renders;
143
+ * a scheme or density change still does not.
144
+ */
145
+ export const useNavLayout = ({
146
+ iconsBelow = DEFAULT_ICONS_BELOW,
147
+ menuBelow = DEFAULT_MENU_BELOW,
148
+ storageKey,
149
+ }: UseNavLayoutOptions = {}): NavLayoutState => {
150
+ // Read once, lazily — a `useState` initialiser, so the stored
151
+ // choice is in place for the first paint and the rail does not
152
+ // open wide and then snap shut.
153
+ const [mode, setStoredMode] = useState<NavLayoutMode>(
154
+ () =>
155
+ (storageKey === undefined
156
+ ? null
157
+ : readStoredMode(storageKey)) ?? "auto",
158
+ )
159
+
160
+ const { isMatching: isBelowMenu } = useMediaQuery({
161
+ matcher: matchMediaMatcher(`(width < ${menuBelow})`),
162
+ })
163
+
164
+ const { isMatching: isBelowIcons } = useMediaQuery({
165
+ matcher: matchMediaMatcher(`(width < ${iconsBelow})`),
166
+ })
167
+
168
+ const setMode = (nextMode: NavLayoutMode) => {
169
+ setStoredMode(nextMode)
170
+
171
+ if (storageKey !== undefined) {
172
+ writeStoredMode(storageKey, nextMode)
173
+ }
174
+ }
175
+
176
+ const autoLayout: ResolvedNavLayout = isBelowIcons
177
+ ? "railIcons"
178
+ : "rail"
179
+
180
+ const layout: ResolvedNavLayout = isBelowMenu
181
+ ? "menu"
182
+ : mode === "auto"
183
+ ? autoLayout
184
+ : mode === "collapsed"
185
+ ? "railIcons"
186
+ : "rail"
187
+
188
+ return {
189
+ isCollapsed: layout === "railIcons",
190
+ layout,
191
+ mode,
192
+ setMode,
193
+ toggle: () => {
194
+ setMode(layout === "rail" ? "collapsed" : "expanded")
195
+ },
196
+ }
197
+ }
@@ -6,6 +6,32 @@ import * as NavBarStories from "./NavBar.stories";
6
6
 
7
7
  # NavBar
8
8
 
9
+ > ## ⚠️ Deprecated — use [`Nav`](?path=/docs/components-layout-nav--docs)
10
+ >
11
+ > **Removed in the next major.** `NavBar` is now a thin adapter over `Nav` and keeps every
12
+ > behaviour documented on this page, so nothing breaks today.
13
+ >
14
+ > ```tsx
15
+ > // Before
16
+ > <NavBar currentHref={pathname} items={DESTINATIONS} label="Main" />
17
+ >
18
+ > // After — `bar` is Nav's default layout
19
+ > <Nav activeHref={pathname} items={DESTINATIONS} label="Main" />
20
+ > ```
21
+ >
22
+ > `currentHref` is `activeHref`. Every other prop keeps its name.
23
+ >
24
+ > **Two items can no longer both be current.** This component asked the question once per
25
+ > item, so a bar holding `/settings` and `/settings/labels` marked *both* while the reader was
26
+ > on the deeper one — against the decision record below, which says a nav with two current
27
+ > items has stopped saying anything. `Nav` takes the deepest match.
28
+ >
29
+ > **Why it went away:** it answered half the question. A horizontal row that folds is one
30
+ > shape an app's destinations take; a labelled side rail, a collapsed icon rail and a
31
+ > Narrow-View bottom strip are the others, and this component could not become any of them —
32
+ > so an app that wanted a rail was back to hand-rolling, which is the state it was built to
33
+ > end.
34
+
9
35
  The app's destinations as real links, in one line — folding into a single menu button,
10
36
  **whole**, when that line does not fit.
11
37