@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
|
@@ -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
|
+
}
|
package/src/NavBar/NavBar.mdx
CHANGED
|
@@ -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
|
|