@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.
Files changed (76) hide show
  1. package/README.md +11 -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/Board/useBoardDrag.d.ts +15 -1
  11. package/dist/Board/useBoardDrag.d.ts.map +1 -1
  12. package/dist/Board/useBoardDrag.js +3 -3
  13. package/dist/Board/useBoardDrag.js.map +1 -1
  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/ReorderList/ReorderList.d.ts +131 -0
  35. package/dist/ReorderList/ReorderList.d.ts.map +1 -0
  36. package/dist/ReorderList/ReorderList.js +154 -0
  37. package/dist/ReorderList/ReorderList.js.map +1 -0
  38. package/dist/index.d.ts +8 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Board/Board.mdx +25 -2
  44. package/src/Board/Board.stories.tsx +52 -1
  45. package/src/Board/Board.test.tsx +82 -0
  46. package/src/Board/Board.tsx +24 -4
  47. package/src/Board/BoardCard.tsx +103 -21
  48. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  49. package/src/Board/useBoardDrag.ts +17 -2
  50. package/src/Nav/Nav.mdx +169 -0
  51. package/src/Nav/Nav.stories.tsx +402 -0
  52. package/src/Nav/Nav.test.tsx +338 -0
  53. package/src/Nav/Nav.tsx +406 -0
  54. package/src/Nav/NavLink.tsx +220 -0
  55. package/src/Nav/navItems.test.ts +163 -0
  56. package/src/Nav/navItems.ts +175 -0
  57. package/src/Nav/useNavLayout.ts +197 -0
  58. package/src/NavBar/NavBar.mdx +26 -0
  59. package/src/NavBar/NavBar.tsx +54 -240
  60. package/src/ReorderList/ReorderList.mdx +83 -0
  61. package/src/ReorderList/ReorderList.stories.tsx +149 -0
  62. package/src/ReorderList/ReorderList.test.tsx +190 -0
  63. package/src/ReorderList/ReorderList.tsx +331 -0
  64. package/src/index.ts +26 -1
  65. package/src/sourceRules.test.ts +26 -1
  66. package/dist/NavBar/NavBarLink.d.ts +0 -43
  67. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  68. package/dist/NavBar/NavBarLink.js +0 -67
  69. package/dist/NavBar/NavBarLink.js.map +0 -1
  70. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  71. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  72. package/dist/NavBar/navBarCurrent.js +0 -48
  73. package/dist/NavBar/navBarCurrent.js.map +0 -1
  74. package/src/NavBar/NavBarLink.tsx +0 -139
  75. package/src/NavBar/navBarCurrent.test.ts +0 -81
  76. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -0,0 +1,338 @@
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 "./Nav.stories.tsx"
14
+
15
+ const { AllStates, AllVariants, Default, Interactive } =
16
+ composeStories(stories)
17
+
18
+ test("the destinations are a named navigation landmark", async () => {
19
+ const { canvas } = await mountStory(Default)
20
+
21
+ const nav = expectAgentDrivable(canvas, {
22
+ name: "Main",
23
+ role: "navigation",
24
+ })
25
+
26
+ await expectNoAxeViolations(nav)
27
+ })
28
+
29
+ /**
30
+ * The whole reason this component exists rather than a row of
31
+ * buttons. A `<button onClick={navigate}>` is indistinguishable from
32
+ * this in a screenshot and loses middle-click, ctrl-click, "open in
33
+ * a new tab", the status bar, and "copy link address".
34
+ */
35
+ test("every destination is a real link with an href", async () => {
36
+ const { canvas } = await mountStory(Default)
37
+
38
+ const links = canvas.getAllByRole("link")
39
+
40
+ expect(links.length).toBe(5)
41
+
42
+ for (const link of links) {
43
+ expect(link).toHaveAttribute("href")
44
+ }
45
+ })
46
+
47
+ test("the current destination is announced, not only coloured", async () => {
48
+ const { canvas } = await mountStory(Default)
49
+
50
+ const current = canvas.getByRole("link", {
51
+ name: "Library",
52
+ })
53
+
54
+ expect(current).toHaveAttribute("aria-current", "page")
55
+
56
+ // And exactly one of them. Two current pages is the same defect as
57
+ // none, one screen reader announcement later.
58
+ expect(
59
+ canvas
60
+ .getAllByRole("link")
61
+ .filter(
62
+ (link: HTMLElement) =>
63
+ link.getAttribute("aria-current") === "page",
64
+ ),
65
+ ).toHaveLength(1)
66
+ })
67
+
68
+ /**
69
+ * A collapsed rail shows a glyph and nothing else. The label has to
70
+ * survive into the accessible tree or the whole column is unnamed —
71
+ * and this is the state where `NavItem.icon` being required stops
72
+ * being pedantry.
73
+ */
74
+ test("a collapsed rail keeps every destination's name", async () => {
75
+ const { canvas } = await mountStory(AllVariants)
76
+
77
+ const rail = canvas.getByRole("navigation", {
78
+ name: "Rail without labels",
79
+ })
80
+
81
+ const links = within(rail).getAllByRole("link")
82
+
83
+ expect(links.length).toBe(5)
84
+
85
+ for (const link of links) {
86
+ // A `title` for the pointer, and real text for everyone else.
87
+ // `aria-label` is deliberately not used: it would replace the
88
+ // content rather than name it.
89
+ expect(link).toHaveAttribute("title")
90
+
91
+ expect(link.textContent?.trim()).not.toBe("")
92
+ }
93
+
94
+ await expectNoAxeViolations(rail)
95
+ })
96
+
97
+ /**
98
+ * `/library/a-flock-of-seagulls?sort=year` is inside Library. The
99
+ * item matched on arrival and has to keep matching once a filter is
100
+ * applied, which is the case that breaks first in a real app.
101
+ */
102
+ test("a filtered page inside a destination is still that destination", async () => {
103
+ const { canvas } = await mountStory(AllStates)
104
+
105
+ const nav = canvas.getByRole("navigation", {
106
+ name: "Current deeper",
107
+ })
108
+
109
+ const current = within(nav)
110
+ .getAllByRole("link")
111
+ .filter(
112
+ (link: HTMLElement) =>
113
+ link.getAttribute("aria-current") === "page",
114
+ )
115
+
116
+ expect(current).toHaveLength(1)
117
+
118
+ expect(current[0]?.textContent).toContain("Library")
119
+ })
120
+
121
+ test("a destination that leaves the app opens in a new tab and is never current", async () => {
122
+ const { canvas } = await mountStory(AllStates)
123
+
124
+ const nav = canvas.getByRole("navigation", {
125
+ name: "Long and external",
126
+ })
127
+
128
+ const external = within(nav)
129
+ .getAllByRole("link")
130
+ .find((link: HTMLElement) =>
131
+ link.getAttribute("href")?.startsWith("https://"),
132
+ )
133
+
134
+ expect(external).toBeDefined()
135
+
136
+ expect(external).toHaveAttribute("target", "_blank")
137
+
138
+ // `noreferrer` beside `noopener` on purpose: one closes the
139
+ // `window.opener` hole, the other keeps a private app's URL out of
140
+ // the destination's logs.
141
+ expect(external).toHaveAttribute(
142
+ "rel",
143
+ "noopener noreferrer",
144
+ )
145
+
146
+ expect(external).not.toHaveAttribute("aria-current")
147
+
148
+ // Said out loud as well as drawn, WCAG 1.4.1.
149
+ expect(external?.textContent).toContain(
150
+ "opens in a new tab",
151
+ )
152
+ })
153
+
154
+ /**
155
+ * The Narrow View's whole keyboard path. The panel holds **links**,
156
+ * not menu items — which is the difference between this and `Menu`,
157
+ * and the reason it is a `Popover` underneath.
158
+ */
159
+ test("the narrow menu opens from its trigger and holds real links", async () => {
160
+ const { body, canvas } = await mountStory(Interactive)
161
+
162
+ const trigger = expectAgentDrivable(canvas, {
163
+ name: "Menu",
164
+ role: "button",
165
+ })
166
+
167
+ await userEvent.click(trigger)
168
+
169
+ await waitFor(() => {
170
+ expect(
171
+ body.getByRole("navigation", { name: "Main" }),
172
+ ).toBeInTheDocument()
173
+ })
174
+
175
+ const panel = body.getByRole("navigation", {
176
+ name: "Main",
177
+ })
178
+
179
+ expect(within(panel).getAllByRole("link")).toHaveLength(6)
180
+
181
+ await expectNoAxeViolations(panel)
182
+ })
183
+
184
+ test("escape closes the narrow menu", async () => {
185
+ const { body, canvas } = await mountStory(Interactive)
186
+
187
+ await userEvent.click(
188
+ expectAgentDrivable(canvas, {
189
+ name: "Menu",
190
+ role: "button",
191
+ }),
192
+ )
193
+
194
+ await waitFor(() => {
195
+ expect(
196
+ body.getByRole("navigation", { name: "Main" }),
197
+ ).toBeInTheDocument()
198
+ })
199
+
200
+ await userEvent.keyboard("{Escape}")
201
+
202
+ await waitFor(() => {
203
+ expect(
204
+ body.queryByRole("navigation", { name: "Main" }),
205
+ ).toBeNull()
206
+ })
207
+ })
208
+
209
+ /**
210
+ * A long destination name must not become the rail's floor. Only
211
+ * `truncate` (or `wrap-anywhere`) shrinks the min-content size a
212
+ * flex item's automatic minimum resolves against — `min-w-0` alone
213
+ * does not, and the two are indistinguishable by eye until a column
214
+ * is pushed out of the viewport.
215
+ */
216
+ test("a long label truncates rather than widening the column", async () => {
217
+ const { canvas } = await mountStory(AllStates)
218
+
219
+ const nav = canvas.getByRole("navigation", {
220
+ name: "Long and external",
221
+ })
222
+
223
+ const long = within(nav).getByRole("link", {
224
+ name: /Publisher catalogue/,
225
+ })
226
+
227
+ const label = long.querySelector("span:nth-of-type(2)")
228
+
229
+ expect(label).not.toBeNull()
230
+
231
+ expect(
232
+ label && getComputedStyle(label).textOverflow,
233
+ ).toBe("ellipsis")
234
+ })
235
+
236
+ /**
237
+ * The trigger exists **only when the row did not fit**. mux-magic
238
+ * shows its overflow glyph at every width, which is why its collapse
239
+ * is not a collapse at all — almost everything lives behind the
240
+ * button permanently.
241
+ */
242
+ test("a bar with room shows no menu trigger", async () => {
243
+ const { canvas } = await mountStory(Default)
244
+
245
+ const nav = canvas.getByRole("navigation", {
246
+ name: "Main",
247
+ })
248
+
249
+ await waitFor(() => {
250
+ expect(
251
+ within(nav).queryByRole("button", {
252
+ name: "Main menu",
253
+ }),
254
+ ).toBeNull()
255
+ })
256
+
257
+ expect(within(nav).getAllByRole("link")).toHaveLength(5)
258
+ })
259
+
260
+ /**
261
+ * All or nothing — the decision `NavBar` established and `Nav` kept.
262
+ *
263
+ * `Toolbar` would leave two of the five in the bar and put three
264
+ * behind the button, which splits the product's own order across two
265
+ * places with no rule the reader can learn.
266
+ */
267
+ test("a bar that does not fit folds every destination together", async () => {
268
+ const { canvas } = await mountStory(AllVariants)
269
+
270
+ const nav = canvas.getByRole("navigation", {
271
+ name: "Main, folded",
272
+ })
273
+
274
+ await waitFor(() => {
275
+ expect(
276
+ within(nav).getByRole("button", {
277
+ name: "Main menu",
278
+ }),
279
+ ).toBeInTheDocument()
280
+ })
281
+
282
+ // Not "fewer links" — none. A half-folded bar is the state this
283
+ // component refuses to enter.
284
+ expect(within(nav).queryAllByRole("link")).toHaveLength(0)
285
+ })
286
+
287
+ /**
288
+ * The whole reason the folded panel is a `Popover` and not a `Menu`.
289
+ * A `MenuItem` is `{ label, onSelect }`, so a nav folded into one is
290
+ * a row of `<button>`s — identical in a screenshot, and with no
291
+ * `href` for middle-click, ctrl-click or "copy link address".
292
+ */
293
+ test("a folded destination is still a real link", async () => {
294
+ const { body, canvas } = await mountStory(AllVariants)
295
+
296
+ const nav = canvas.getByRole("navigation", {
297
+ name: "Main, folded",
298
+ })
299
+
300
+ await userEvent.click(
301
+ within(nav).getByRole("button", { name: "Main menu" }),
302
+ )
303
+
304
+ // Scoped to the panel, not the document: this board draws five
305
+ // navs and every one of them has a "Tonight".
306
+ await waitFor(() => {
307
+ expect(
308
+ body.getByRole("dialog", { name: "Main menu" }),
309
+ ).toBeInTheDocument()
310
+ })
311
+
312
+ const panel = body.getByRole("dialog", {
313
+ name: "Main menu",
314
+ })
315
+
316
+ expect(within(panel).getAllByRole("link")).toHaveLength(5)
317
+
318
+ expect(
319
+ within(panel).getByRole("link", { name: "Tonight" }),
320
+ ).toHaveAttribute("href", "/tonight")
321
+
322
+ await expectNoAxeViolations(panel)
323
+ })
324
+
325
+ /**
326
+ * A destination is in the bar or in the panel, never both. The
327
+ * fleet's habit is to render the whole nav twice and hide one copy
328
+ * with `hidden md:flex`, which puts every link in the DOM at every
329
+ * width — so an agent driving the page finds two of each and cannot
330
+ * tell which one a human can see.
331
+ */
332
+ test("a destination is mounted exactly once", async () => {
333
+ const { canvas } = await mountStory(Default)
334
+
335
+ expect(
336
+ canvas.getAllByRole("link", { name: "Tonight" }),
337
+ ).toHaveLength(1)
338
+ })