@charcuterie/ui 3.21.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/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/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,350 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
controlSizeArgType,
|
|
6
|
+
placementArgType,
|
|
7
|
+
} from "../argTypes.storyHelpers.ts"
|
|
8
|
+
import { Button } from "../Button/Button.tsx"
|
|
9
|
+
import {
|
|
10
|
+
ContainerBoard,
|
|
11
|
+
StoryCell,
|
|
12
|
+
StoryGrid,
|
|
13
|
+
} from "../board.storyHelpers.tsx"
|
|
14
|
+
import { Header } from "../Header/Header.tsx"
|
|
15
|
+
import { MenuIcon } from "../icons.storyHelpers.tsx"
|
|
16
|
+
import { Main } from "../Main/Main.tsx"
|
|
17
|
+
import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
|
|
18
|
+
import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
|
|
19
|
+
import { Shell } from "../Shell/Shell.tsx"
|
|
20
|
+
import { PageContent } from "../shell.storyHelpers.tsx"
|
|
21
|
+
import type { NavBarItem } from "./NavBar.tsx"
|
|
22
|
+
import { NavBar } from "./NavBar.tsx"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A stand-in for react-router's `<Link>`, and it does the one thing
|
|
26
|
+
* the plain `SoftRouterLink` in the other link boards does not:
|
|
27
|
+
* `preventDefault()`.
|
|
28
|
+
*
|
|
29
|
+
* That is not a test convenience, it is what a router does — it
|
|
30
|
+
* intercepts the plain left click and pushes history instead of
|
|
31
|
+
* letting the browser leave the page. Without it these boards
|
|
32
|
+
* genuinely navigate: the first run of `NavBar.test.tsx` clicked
|
|
33
|
+
* "Phases" and took the whole vitest iframe to `/phases`, which is
|
|
34
|
+
* the most direct proof available that these rows are real anchors
|
|
35
|
+
* and not `<button>`s.
|
|
36
|
+
*
|
|
37
|
+
* The `href` is untouched, so middle-click and ctrl-click still go
|
|
38
|
+
* to the platform — exactly as they do in an app.
|
|
39
|
+
*/
|
|
40
|
+
const SoftRouterLink = ({
|
|
41
|
+
href,
|
|
42
|
+
onClick,
|
|
43
|
+
...linkProps
|
|
44
|
+
}: RouterLinkProps): ReactNode => (
|
|
45
|
+
<a
|
|
46
|
+
{...linkProps}
|
|
47
|
+
data-router="soft"
|
|
48
|
+
href={href}
|
|
49
|
+
onClick={(event) => {
|
|
50
|
+
// The caller's handler first, then the interception — which
|
|
51
|
+
// is react-router's own order in `Link`, and it is what lets
|
|
52
|
+
// `NavBar` see a click that has not been cancelled yet.
|
|
53
|
+
onClick?.(event)
|
|
54
|
+
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Docket's nine, in the product's own order — the nav that outgrew
|
|
62
|
+
* a single line and is the reason this component exists. The order
|
|
63
|
+
* is the pipeline work moves along, which is exactly why it may not
|
|
64
|
+
* be split across a bar and a menu.
|
|
65
|
+
*/
|
|
66
|
+
const DESTINATIONS: NavBarItem[] = [
|
|
67
|
+
{ href: "/triage", label: "Triage" },
|
|
68
|
+
{ href: "/backlog", label: "Backlog" },
|
|
69
|
+
{ href: "/phases", label: "Phases" },
|
|
70
|
+
{ href: "/lanes", label: "Lanes" },
|
|
71
|
+
{ href: "/board", label: "Board" },
|
|
72
|
+
{ href: "/archives", label: "Archives" },
|
|
73
|
+
{ href: "/tonight", label: "Tonight" },
|
|
74
|
+
{ href: "/chats", label: "Chats" },
|
|
75
|
+
{ href: "/settings", label: "Settings" },
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
const FEW_DESTINATIONS = DESTINATIONS.slice(0, 3)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A fixed-width box, because the whole component is a measurement.
|
|
82
|
+
* A `layout: "centered"` story would size itself to its contents
|
|
83
|
+
* and never fold at all.
|
|
84
|
+
*/
|
|
85
|
+
const Frame = ({
|
|
86
|
+
children,
|
|
87
|
+
inlineSize,
|
|
88
|
+
}: {
|
|
89
|
+
children: ReactNode
|
|
90
|
+
inlineSize: string
|
|
91
|
+
}): ReactNode => (
|
|
92
|
+
<div
|
|
93
|
+
className="flex rounded-md border border-border-subtle bg-surface-raised p-2"
|
|
94
|
+
style={{ inlineSize }}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const meta = {
|
|
101
|
+
title: "Components/Layout/NavBar",
|
|
102
|
+
component: NavBar,
|
|
103
|
+
decorators: [
|
|
104
|
+
(Story) => (
|
|
105
|
+
<RouterLinkProvider link={SoftRouterLink}>
|
|
106
|
+
<Story />
|
|
107
|
+
</RouterLinkProvider>
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
parameters: { layout: "padded" },
|
|
111
|
+
argTypes: {
|
|
112
|
+
menuPlacement: placementArgType,
|
|
113
|
+
size: controlSizeArgType,
|
|
114
|
+
},
|
|
115
|
+
args: {
|
|
116
|
+
currentHref: "/board",
|
|
117
|
+
items: DESTINATIONS,
|
|
118
|
+
label: "Main",
|
|
119
|
+
menuIcon: <MenuIcon />,
|
|
120
|
+
menuLabel: "Main menu",
|
|
121
|
+
size: "sm",
|
|
122
|
+
},
|
|
123
|
+
} satisfies Meta<typeof NavBar>
|
|
124
|
+
|
|
125
|
+
export default meta
|
|
126
|
+
|
|
127
|
+
type Story = StoryObj<typeof meta>
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wide enough for all nine, so there is **no menu button at all** —
|
|
131
|
+
* a trigger that shows at every width is not a fold, it is a nav
|
|
132
|
+
* you have permanently hidden.
|
|
133
|
+
*/
|
|
134
|
+
export const Default: Story = {
|
|
135
|
+
render: (navBarProps) => (
|
|
136
|
+
<Frame inlineSize="52rem">
|
|
137
|
+
<NavBar {...navBarProps} />
|
|
138
|
+
</Frame>
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The two states, and there is deliberately no third.
|
|
144
|
+
*
|
|
145
|
+
* `Toolbar` drops items one at a time from the end because its items
|
|
146
|
+
* are **ranked**. A nav's order is the product's shape, not a
|
|
147
|
+
* priority list, so half of it in the bar and half behind a button
|
|
148
|
+
* is a rule no reader can learn.
|
|
149
|
+
*/
|
|
150
|
+
export const AllVariants: Story = {
|
|
151
|
+
render: ({ currentHref, size }) => (
|
|
152
|
+
<StoryGrid columns={1}>
|
|
153
|
+
<StoryCell label="It fits — every destination is a link">
|
|
154
|
+
<Frame inlineSize="52rem">
|
|
155
|
+
<NavBar
|
|
156
|
+
currentHref={currentHref}
|
|
157
|
+
items={DESTINATIONS}
|
|
158
|
+
label="Main, wide"
|
|
159
|
+
menuIcon={<MenuIcon />}
|
|
160
|
+
menuLabel="Main menu"
|
|
161
|
+
size={size}
|
|
162
|
+
/>
|
|
163
|
+
</Frame>
|
|
164
|
+
</StoryCell>
|
|
165
|
+
|
|
166
|
+
<StoryCell label="It does not fit — all nine fold together">
|
|
167
|
+
<Frame inlineSize="18rem">
|
|
168
|
+
<NavBar
|
|
169
|
+
currentHref={currentHref}
|
|
170
|
+
items={DESTINATIONS}
|
|
171
|
+
label="Main, narrow"
|
|
172
|
+
menuIcon={<MenuIcon />}
|
|
173
|
+
menuLabel="Main menu"
|
|
174
|
+
size={size}
|
|
175
|
+
/>
|
|
176
|
+
</Frame>
|
|
177
|
+
</StoryCell>
|
|
178
|
+
|
|
179
|
+
<StoryCell
|
|
180
|
+
label={
|
|
181
|
+
'Folded, menuAlign="end" — the trigger joins the header\'s own actions'
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<Frame inlineSize="18rem">
|
|
185
|
+
<NavBar
|
|
186
|
+
currentHref={currentHref}
|
|
187
|
+
items={DESTINATIONS}
|
|
188
|
+
label="Main, trigger at the end"
|
|
189
|
+
menuAlign="end"
|
|
190
|
+
menuIcon={<MenuIcon />}
|
|
191
|
+
menuLabel="Main menu"
|
|
192
|
+
size={size}
|
|
193
|
+
/>
|
|
194
|
+
</Frame>
|
|
195
|
+
</StoryCell>
|
|
196
|
+
|
|
197
|
+
<StoryCell label="No icon — the trigger is its own label">
|
|
198
|
+
<Frame inlineSize="18rem">
|
|
199
|
+
<NavBar
|
|
200
|
+
currentHref={currentHref}
|
|
201
|
+
items={DESTINATIONS}
|
|
202
|
+
label="Main, worded trigger"
|
|
203
|
+
menuLabel="Menu"
|
|
204
|
+
size={size}
|
|
205
|
+
/>
|
|
206
|
+
</Frame>
|
|
207
|
+
</StoryCell>
|
|
208
|
+
</StoryGrid>
|
|
209
|
+
),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Folded and open is the state nobody screenshots, and it is the
|
|
214
|
+
* one that matters: the panel is the **complete** list, in the
|
|
215
|
+
* product's order, with the current destination marked in it.
|
|
216
|
+
*/
|
|
217
|
+
export const AllStates: Story = {
|
|
218
|
+
render: ({ size }) => (
|
|
219
|
+
<StoryGrid columns={1}>
|
|
220
|
+
<StoryCell label="Current destination, in the bar">
|
|
221
|
+
<Frame inlineSize="52rem">
|
|
222
|
+
<NavBar
|
|
223
|
+
currentHref="/board"
|
|
224
|
+
items={DESTINATIONS}
|
|
225
|
+
label="Main, current in bar"
|
|
226
|
+
menuIcon={<MenuIcon />}
|
|
227
|
+
menuLabel="Main menu"
|
|
228
|
+
size={size}
|
|
229
|
+
/>
|
|
230
|
+
</Frame>
|
|
231
|
+
</StoryCell>
|
|
232
|
+
|
|
233
|
+
<StoryCell label="On a route no destination owns — nothing is current">
|
|
234
|
+
<Frame inlineSize="52rem">
|
|
235
|
+
<NavBar
|
|
236
|
+
currentHref="/tasks/41"
|
|
237
|
+
items={FEW_DESTINATIONS}
|
|
238
|
+
label="Main, nothing current"
|
|
239
|
+
menuIcon={<MenuIcon />}
|
|
240
|
+
menuLabel="Main menu"
|
|
241
|
+
size={size}
|
|
242
|
+
/>
|
|
243
|
+
</Frame>
|
|
244
|
+
</StoryCell>
|
|
245
|
+
|
|
246
|
+
<StoryCell label="Folded, menu open — the complete list">
|
|
247
|
+
<Frame inlineSize="18rem">
|
|
248
|
+
<NavBar
|
|
249
|
+
currentHref="/board"
|
|
250
|
+
isMenuVisible
|
|
251
|
+
items={DESTINATIONS}
|
|
252
|
+
label="Main, folded and open"
|
|
253
|
+
menuIcon={<MenuIcon />}
|
|
254
|
+
menuLabel="Main menu"
|
|
255
|
+
size={size}
|
|
256
|
+
/>
|
|
257
|
+
</Frame>
|
|
258
|
+
</StoryCell>
|
|
259
|
+
</StoryGrid>
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Measured rather than breakpointed. The same nine destinations at
|
|
265
|
+
* three container widths, with no pixel number anywhere in the
|
|
266
|
+
* component — so a longer label, a tenth destination or a denser
|
|
267
|
+
* theme moves the fold on its own.
|
|
268
|
+
*/
|
|
269
|
+
export const Responsive: Story = {
|
|
270
|
+
render: ({ currentHref, size }) => (
|
|
271
|
+
<ContainerBoard>
|
|
272
|
+
{(width) => (
|
|
273
|
+
<Frame inlineSize="100%">
|
|
274
|
+
<NavBar
|
|
275
|
+
currentHref={currentHref}
|
|
276
|
+
items={DESTINATIONS}
|
|
277
|
+
label={`Main at ${width}`}
|
|
278
|
+
menuIcon={<MenuIcon />}
|
|
279
|
+
menuLabel="Main menu"
|
|
280
|
+
size={size}
|
|
281
|
+
/>
|
|
282
|
+
</Frame>
|
|
283
|
+
)}
|
|
284
|
+
</ContainerBoard>
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Tab to the trigger, Enter to open, and focus moves into the
|
|
290
|
+
* panel; Escape closes it and hands focus back. Every row is a real
|
|
291
|
+
* `<a href>`, so ctrl-click and middle-click still open a tab —
|
|
292
|
+
* which is the entire reason this is not a `Menu`.
|
|
293
|
+
*/
|
|
294
|
+
export const Interactive: Story = {
|
|
295
|
+
render: ({ currentHref, size }) => (
|
|
296
|
+
<Frame inlineSize="18rem">
|
|
297
|
+
<NavBar
|
|
298
|
+
currentHref={currentHref}
|
|
299
|
+
items={DESTINATIONS}
|
|
300
|
+
label="Main, keyboard"
|
|
301
|
+
menuIcon={<MenuIcon />}
|
|
302
|
+
menuLabel="Main menu"
|
|
303
|
+
size={size}
|
|
304
|
+
/>
|
|
305
|
+
</Frame>
|
|
306
|
+
),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Where it actually goes: `Shell` → `Header`, with the wordmark
|
|
311
|
+
* before it and the page's own action after it.
|
|
312
|
+
*
|
|
313
|
+
* The header row is a flex line, so the bar's `flex-1 min-w-0` is
|
|
314
|
+
* what gives it a box sized by the row rather than by its own
|
|
315
|
+
* links — the one arrangement rule the component cannot enforce
|
|
316
|
+
* from the inside.
|
|
317
|
+
*/
|
|
318
|
+
const AppShellHarness = (): ReactNode => (
|
|
319
|
+
<Shell>
|
|
320
|
+
<Header
|
|
321
|
+
actions={
|
|
322
|
+
<Button intent="accent" size="sm">
|
|
323
|
+
Add Task
|
|
324
|
+
</Button>
|
|
325
|
+
}
|
|
326
|
+
heading="Docket"
|
|
327
|
+
>
|
|
328
|
+
<NavBar
|
|
329
|
+
currentHref="/board"
|
|
330
|
+
items={DESTINATIONS}
|
|
331
|
+
label="Main"
|
|
332
|
+
// The header's own action is at the far edge, so the fold
|
|
333
|
+
// control joins it rather than hugging the wordmark with
|
|
334
|
+
// the whole track empty after it.
|
|
335
|
+
menuAlign="end"
|
|
336
|
+
menuIcon={<MenuIcon />}
|
|
337
|
+
menuLabel="Main menu"
|
|
338
|
+
/>
|
|
339
|
+
</Header>
|
|
340
|
+
|
|
341
|
+
<Main>
|
|
342
|
+
<PageContent />
|
|
343
|
+
</Main>
|
|
344
|
+
</Shell>
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
export const InAppShell: Story = {
|
|
348
|
+
parameters: { layout: "fullscreen" },
|
|
349
|
+
render: () => <AppShellHarness />,
|
|
350
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import {
|
|
3
|
+
expect,
|
|
4
|
+
userEvent,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from "storybook/test"
|
|
8
|
+
import { test } from "vitest"
|
|
9
|
+
|
|
10
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
11
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
12
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
13
|
+
import * as stories from "./NavBar.stories.tsx"
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
AllStates,
|
|
17
|
+
AllVariants,
|
|
18
|
+
Default,
|
|
19
|
+
InAppShell,
|
|
20
|
+
Interactive,
|
|
21
|
+
} = composeStories(stories)
|
|
22
|
+
|
|
23
|
+
test("the nav is named, and its destinations are links", async () => {
|
|
24
|
+
const { canvas } = await mountStory(Default)
|
|
25
|
+
|
|
26
|
+
const nav = expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Main",
|
|
28
|
+
role: "navigation",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const board = expectAgentDrivable(within(nav), {
|
|
32
|
+
name: "Board",
|
|
33
|
+
role: "link",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A `<button onClick={navigate}>` looks identical in a screenshot
|
|
37
|
+
// and satisfies "the nav works". It has no `href`, so middle-click,
|
|
38
|
+
// ctrl-click and "copy link address" all do nothing.
|
|
39
|
+
await expect(board).toHaveAttribute("href", "/board")
|
|
40
|
+
|
|
41
|
+
await expectNoAxeViolations(nav)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The trigger exists **only when the row did not fit**. mux-magic
|
|
46
|
+
* shows its overflow glyph at every width, which is why its collapse
|
|
47
|
+
* is not a collapse at all — almost everything lives behind the
|
|
48
|
+
* button permanently.
|
|
49
|
+
*/
|
|
50
|
+
test("a bar with room shows no menu trigger", async () => {
|
|
51
|
+
const { canvas } = await mountStory(Default)
|
|
52
|
+
|
|
53
|
+
const nav = expectAgentDrivable(canvas, {
|
|
54
|
+
name: "Main",
|
|
55
|
+
role: "navigation",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(
|
|
60
|
+
within(nav).queryByRole("button", {
|
|
61
|
+
name: "Main menu",
|
|
62
|
+
}),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
within(nav).getAllByRole("link"),
|
|
68
|
+
).toHaveLength(9)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* All or nothing — the decision this component exists to hold.
|
|
73
|
+
*
|
|
74
|
+
* `Toolbar` would leave four of the nine in the bar at this width
|
|
75
|
+
* and put five behind the button, which splits the product's own
|
|
76
|
+
* order across two places with no rule the reader can learn.
|
|
77
|
+
*/
|
|
78
|
+
test("a bar that does not fit folds every destination together", async () => {
|
|
79
|
+
const { canvas } = await mountStory(AllVariants)
|
|
80
|
+
|
|
81
|
+
const nav = expectAgentDrivable(canvas, {
|
|
82
|
+
name: "Main, narrow",
|
|
83
|
+
role: "navigation",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expectAgentDrivable(within(nav), {
|
|
87
|
+
name: "Main menu",
|
|
88
|
+
role: "button",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Not "fewer links" — no links.
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(within(nav).queryAllByRole("link")).toHaveLength(
|
|
94
|
+
0,
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The defect this component refuses to introduce, and the reason it
|
|
101
|
+
* is not a folded `Toolbar`: `MenuItem` is `{ label, onSelect }`, so
|
|
102
|
+
* every row in the panel would be a `<button>` — no new tab, no
|
|
103
|
+
* status bar, nothing to copy.
|
|
104
|
+
*/
|
|
105
|
+
test("a folded destination is still a real link", async () => {
|
|
106
|
+
const { body } = await mountStory(AllStates)
|
|
107
|
+
|
|
108
|
+
const panel = expectAgentDrivable(body, {
|
|
109
|
+
name: "Main menu",
|
|
110
|
+
role: "dialog",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const archives = expectAgentDrivable(within(panel), {
|
|
114
|
+
name: "Archives",
|
|
115
|
+
role: "link",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await expect(archives).toHaveAttribute(
|
|
119
|
+
"href",
|
|
120
|
+
"/archives",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// The complete list, in the product's order — a folded nav is not
|
|
124
|
+
// an abridged one.
|
|
125
|
+
await expect(
|
|
126
|
+
within(panel)
|
|
127
|
+
.getAllByRole("link")
|
|
128
|
+
.map((link) => link.textContent),
|
|
129
|
+
).toEqual([
|
|
130
|
+
"Triage",
|
|
131
|
+
"Backlog",
|
|
132
|
+
"Phases",
|
|
133
|
+
"Lanes",
|
|
134
|
+
"Board",
|
|
135
|
+
"Archives",
|
|
136
|
+
"Tonight",
|
|
137
|
+
"Chats",
|
|
138
|
+
"Settings",
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The fleet's habit is to render the nav twice and hide one copy
|
|
144
|
+
* with `hidden md:flex` — so every link is in the DOM at every
|
|
145
|
+
* width, and an agent driving the page finds two of each with no way
|
|
146
|
+
* to tell which one a human can see.
|
|
147
|
+
*/
|
|
148
|
+
test("a destination is mounted exactly once", async () => {
|
|
149
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
150
|
+
|
|
151
|
+
const nav = expectAgentDrivable(canvas, {
|
|
152
|
+
name: "Main, folded and open",
|
|
153
|
+
role: "navigation",
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await expect(
|
|
157
|
+
within(nav).queryAllByRole("link"),
|
|
158
|
+
).toHaveLength(0)
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(
|
|
162
|
+
within(
|
|
163
|
+
body.getByRole("dialog", { name: "Main menu" }),
|
|
164
|
+
).getAllByRole("link", { name: "Chats" }),
|
|
165
|
+
).toHaveLength(1)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A highlight is invisible to a screen reader and to anyone who
|
|
171
|
+
* cannot separate the two surfaces, so the current destination says
|
|
172
|
+
* so in the platform's own words — and exactly one does.
|
|
173
|
+
*/
|
|
174
|
+
test("the current destination is marked, and only it", async () => {
|
|
175
|
+
const { canvas } = await mountStory(AllStates)
|
|
176
|
+
|
|
177
|
+
const nav = expectAgentDrivable(canvas, {
|
|
178
|
+
name: "Main, current in bar",
|
|
179
|
+
role: "navigation",
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
within(nav)
|
|
184
|
+
.getAllByRole("link")
|
|
185
|
+
.filter(
|
|
186
|
+
(link) =>
|
|
187
|
+
link.getAttribute("aria-current") === "page",
|
|
188
|
+
)
|
|
189
|
+
.map((link) => link.textContent),
|
|
190
|
+
).toEqual(["Board"])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `/tasks/41` belongs to no destination, and the honest answer is
|
|
195
|
+
* that none is current. react-router's `<NavLink to="/">` would mark
|
|
196
|
+
* the root here unless the caller remembered `end`.
|
|
197
|
+
*/
|
|
198
|
+
test("a route no destination owns marks nothing", async () => {
|
|
199
|
+
const { canvas } = await mountStory(AllStates)
|
|
200
|
+
|
|
201
|
+
const nav = expectAgentDrivable(canvas, {
|
|
202
|
+
name: "Main, nothing current",
|
|
203
|
+
role: "navigation",
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
within(nav)
|
|
208
|
+
.getAllByRole("link")
|
|
209
|
+
.filter((link) => link.hasAttribute("aria-current")),
|
|
210
|
+
).toHaveLength(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A fold control belongs at an edge. `menuAlign="end"` puts it
|
|
215
|
+
* beside the header's own actions instead of hugging the wordmark
|
|
216
|
+
* with the whole track empty after it — and it reaches only the
|
|
217
|
+
* folded row, so the links' own position is not a prop.
|
|
218
|
+
*/
|
|
219
|
+
test("the folded trigger can sit at the end of the track", async () => {
|
|
220
|
+
const { canvas } = await mountStory(AllVariants)
|
|
221
|
+
|
|
222
|
+
const aligned = expectAgentDrivable(canvas, {
|
|
223
|
+
name: "Main, trigger at the end",
|
|
224
|
+
role: "navigation",
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const start = expectAgentDrivable(canvas, {
|
|
228
|
+
name: "Main, narrow",
|
|
229
|
+
role: "navigation",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await waitFor(() => {
|
|
233
|
+
expect(
|
|
234
|
+
within(aligned)
|
|
235
|
+
.getByRole("button", { name: "Main menu" })
|
|
236
|
+
.getBoundingClientRect().left,
|
|
237
|
+
).toBeGreaterThan(
|
|
238
|
+
within(start)
|
|
239
|
+
.getByRole("button", { name: "Main menu" })
|
|
240
|
+
.getBoundingClientRect().left,
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The trigger advertises what it opens, and gets focus back when it
|
|
247
|
+
* closes — both from `useRole` and `FloatingFocusManager` inside
|
|
248
|
+
* `Popover`, so neither can be forgotten here.
|
|
249
|
+
*/
|
|
250
|
+
test("the trigger advertises the panel it opens", async () => {
|
|
251
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
252
|
+
|
|
253
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
254
|
+
name: "Main menu",
|
|
255
|
+
role: "button",
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await expect(trigger).toHaveAttribute(
|
|
259
|
+
"aria-expanded",
|
|
260
|
+
"false",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
await expect(trigger).toHaveAttribute(
|
|
264
|
+
"aria-haspopup",
|
|
265
|
+
"dialog",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
await userEvent.click(trigger)
|
|
269
|
+
|
|
270
|
+
await expect(trigger).toHaveAttribute(
|
|
271
|
+
"aria-expanded",
|
|
272
|
+
"true",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const panel = expectAgentDrivable(body, {
|
|
276
|
+
name: "Main menu",
|
|
277
|
+
role: "dialog",
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(panel).toContainElement(
|
|
282
|
+
document.activeElement as HTMLElement,
|
|
283
|
+
)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await userEvent.keyboard("{Escape}")
|
|
287
|
+
|
|
288
|
+
await waitFor(() => {
|
|
289
|
+
expect(trigger).toHaveFocus()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Choosing a destination closes the menu behind you — the one
|
|
295
|
+
* interaction people read as "it didn't work" when it is missing.
|
|
296
|
+
*/
|
|
297
|
+
test("following a link in the panel closes it", async () => {
|
|
298
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
299
|
+
|
|
300
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
301
|
+
name: "Main menu",
|
|
302
|
+
role: "button",
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await userEvent.click(trigger)
|
|
306
|
+
|
|
307
|
+
const panel = expectAgentDrivable(body, {
|
|
308
|
+
name: "Main menu",
|
|
309
|
+
role: "dialog",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
await userEvent.click(
|
|
313
|
+
within(panel).getByRole("link", { name: "Phases" }),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
await waitFor(() => {
|
|
317
|
+
expect(
|
|
318
|
+
body.queryByRole("dialog", { name: "Main menu" }),
|
|
319
|
+
).toBeNull()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test("the folded nav audits clean while open", async () => {
|
|
324
|
+
const { canvasElement } = await mountStory(AllStates)
|
|
325
|
+
|
|
326
|
+
await expectNoAxeViolations(canvasElement)
|
|
327
|
+
|
|
328
|
+
// The panel is portalled out of the canvas, so the resting audit
|
|
329
|
+
// above cannot see it — an overflow audited only while shut is an
|
|
330
|
+
// overflow nobody audited.
|
|
331
|
+
await expectNoAxeViolations(document.body)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Where it actually goes. `Header`'s row is what sizes the bar; the
|
|
336
|
+
* bar measures that box to decide what to draw in it.
|
|
337
|
+
*/
|
|
338
|
+
test("the shell mounts exactly one nav", async () => {
|
|
339
|
+
const { canvas } = await mountStory(InAppShell)
|
|
340
|
+
|
|
341
|
+
await expect(
|
|
342
|
+
canvas.getAllByRole("navigation", { name: "Main" }),
|
|
343
|
+
).toHaveLength(1)
|
|
344
|
+
})
|