@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.
- package/README.md +11 -0
- package/dist/Board/BoardCard.d.ts +42 -18
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +17 -1
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/DropRail/DropRail.d.ts +109 -0
- package/dist/DropRail/DropRail.d.ts.map +1 -0
- package/dist/DropRail/DropRail.js +277 -0
- package/dist/DropRail/DropRail.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +130 -0
- package/dist/NavBar/NavBar.d.ts.map +1 -0
- package/dist/NavBar/NavBar.js +114 -0
- package/dist/NavBar/NavBar.js.map +1 -0
- package/dist/NavBar/NavBarLink.d.ts +43 -0
- package/dist/NavBar/NavBarLink.d.ts.map +1 -0
- package/dist/NavBar/NavBarLink.js +67 -0
- package/dist/NavBar/NavBarLink.js.map +1 -0
- package/dist/NavBar/navBarCurrent.d.ts +37 -0
- package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
- package/dist/NavBar/navBarCurrent.js +48 -0
- package/dist/NavBar/navBarCurrent.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.stories.tsx +100 -0
- package/src/Board/Board.test.tsx +62 -0
- package/src/Board/BoardCard.tsx +71 -18
- package/src/DropRail/DropRail.mdx +90 -0
- package/src/DropRail/DropRail.stories.tsx +328 -0
- package/src/DropRail/DropRail.test.tsx +154 -0
- package/src/DropRail/DropRail.tsx +496 -0
- package/src/NavBar/NavBar.mdx +157 -0
- package/src/NavBar/NavBar.stories.tsx +350 -0
- package/src/NavBar/NavBar.test.tsx +344 -0
- package/src/NavBar/NavBar.tsx +271 -0
- package/src/NavBar/NavBarLink.tsx +139 -0
- package/src/NavBar/navBarCurrent.test.ts +81 -0
- package/src/NavBar/navBarCurrent.ts +64 -0
- package/src/icons.storyHelpers.tsx +12 -0
- package/src/index.ts +11 -0
- 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,
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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} }`)
|