@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.
Files changed (43) hide show
  1. package/README.md +11 -0
  2. package/dist/Board/BoardCard.d.ts +42 -18
  3. package/dist/Board/BoardCard.d.ts.map +1 -1
  4. package/dist/Board/BoardCard.js +17 -1
  5. package/dist/Board/BoardCard.js.map +1 -1
  6. package/dist/DropRail/DropRail.d.ts +109 -0
  7. package/dist/DropRail/DropRail.d.ts.map +1 -0
  8. package/dist/DropRail/DropRail.js +277 -0
  9. package/dist/DropRail/DropRail.js.map +1 -0
  10. package/dist/NavBar/NavBar.d.ts +130 -0
  11. package/dist/NavBar/NavBar.d.ts.map +1 -0
  12. package/dist/NavBar/NavBar.js +114 -0
  13. package/dist/NavBar/NavBar.js.map +1 -0
  14. package/dist/NavBar/NavBarLink.d.ts +43 -0
  15. package/dist/NavBar/NavBarLink.d.ts.map +1 -0
  16. package/dist/NavBar/NavBarLink.js +67 -0
  17. package/dist/NavBar/NavBarLink.js.map +1 -0
  18. package/dist/NavBar/navBarCurrent.d.ts +37 -0
  19. package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
  20. package/dist/NavBar/navBarCurrent.js +48 -0
  21. package/dist/NavBar/navBarCurrent.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/Board/Board.stories.tsx +100 -0
  28. package/src/Board/Board.test.tsx +62 -0
  29. package/src/Board/BoardCard.tsx +71 -18
  30. package/src/DropRail/DropRail.mdx +90 -0
  31. package/src/DropRail/DropRail.stories.tsx +328 -0
  32. package/src/DropRail/DropRail.test.tsx +154 -0
  33. package/src/DropRail/DropRail.tsx +496 -0
  34. package/src/NavBar/NavBar.mdx +157 -0
  35. package/src/NavBar/NavBar.stories.tsx +350 -0
  36. package/src/NavBar/NavBar.test.tsx +344 -0
  37. package/src/NavBar/NavBar.tsx +271 -0
  38. package/src/NavBar/NavBarLink.tsx +139 -0
  39. package/src/NavBar/navBarCurrent.test.ts +81 -0
  40. package/src/NavBar/navBarCurrent.ts +64 -0
  41. package/src/icons.storyHelpers.tsx +12 -0
  42. package/src/index.ts +11 -0
  43. package/src/sourceRules.test.ts +24 -1
@@ -0,0 +1,328 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import type { DropRailTarget } from "./DropRail.tsx"
9
+ import { DropRail } from "./DropRail.tsx"
10
+
11
+ /**
12
+ * **Invented, all of it** โ€” the same rule `Board.stories.tsx`
13
+ * states and for the same reason. The list this component was built
14
+ * for is a real household's, a published repo must never carry that,
15
+ * and a committed screenshot is worse than a committed string
16
+ * because no text scrub can reach inside a PNG.
17
+ *
18
+ * So these are a fictional studio's project groups. The long ones
19
+ * are long on purpose: the rail's whole claim is that it stays
20
+ * usable at a size where a menu does not.
21
+ */
22
+ const STUDIO_TARGETS: DropRailTarget[] = [
23
+ {
24
+ count: 41,
25
+ isCurrent: true,
26
+ key: "atlas",
27
+ label: "Atlas Ingest",
28
+ mark: "๐Ÿ“ฅ",
29
+ },
30
+ {
31
+ count: 53,
32
+ key: "ferry",
33
+ label: "Ferry Docs",
34
+ mark: "๐Ÿ“„",
35
+ },
36
+ {
37
+ count: 94,
38
+ key: "loom",
39
+ label: "Loom Fleet",
40
+ mark: "๐Ÿงต",
41
+ },
42
+ {
43
+ count: 12,
44
+ isDisabled: true,
45
+ key: "signal",
46
+ label: "Signal Kitchen โ€” archived",
47
+ mark: "๐Ÿ—„๏ธ",
48
+ },
49
+ ]
50
+
51
+ /**
52
+ * Thirty-four groups, which is the number that made a menu the
53
+ * wrong control. Long labels included, because a rail that only
54
+ * looks right with short ones has not been tested.
55
+ */
56
+ const MANY_TARGETS: DropRailTarget[] = [
57
+ {
58
+ count: 94,
59
+ key: "loom",
60
+ label: "Loom Fleet",
61
+ mark: "๐Ÿงต",
62
+ },
63
+ {
64
+ count: 81,
65
+ key: "atlas-fix",
66
+ label:
67
+ "Atlas Ingest โ€” repair the malformed series folders",
68
+ mark: "๐Ÿ”ง",
69
+ },
70
+ {
71
+ count: 77,
72
+ isCurrent: true,
73
+ key: "atlas-add",
74
+ label: "Atlas Ingest โ€” add media",
75
+ mark: "โž•",
76
+ },
77
+ {
78
+ count: 75,
79
+ key: "ferry",
80
+ label: "Ferry Docs",
81
+ mark: "๐Ÿ“„",
82
+ },
83
+ {
84
+ count: 67,
85
+ key: "swap",
86
+ label: "Swap to better copy",
87
+ mark: "๐Ÿ”„",
88
+ },
89
+ {
90
+ count: 53,
91
+ key: "reels",
92
+ label: "Reels and Shorts",
93
+ mark: "๐ŸŽฌ",
94
+ },
95
+ {
96
+ count: 46,
97
+ key: "configs",
98
+ label: "Tooling Configs",
99
+ mark: "โš™๏ธ",
100
+ },
101
+ {
102
+ count: 41,
103
+ key: "upkeep",
104
+ label: "Studio Upkeep",
105
+ mark: "๐Ÿงบ",
106
+ },
107
+ {
108
+ count: 37,
109
+ key: "triage",
110
+ label: "Keep or cut?",
111
+ mark: "๐Ÿค”",
112
+ },
113
+ {
114
+ count: 29,
115
+ key: "hunt",
116
+ label: "Find a better source",
117
+ mark: "๐Ÿ”Ž",
118
+ },
119
+ {
120
+ count: 22,
121
+ key: "colour",
122
+ label: "Colour pass",
123
+ mark: "๐ŸŽจ",
124
+ },
125
+ {
126
+ count: 18,
127
+ key: "audio",
128
+ label: "Audio conform",
129
+ mark: "๐ŸŽง",
130
+ },
131
+ {
132
+ count: 11,
133
+ key: "subs",
134
+ label: "Subtitle timing",
135
+ mark: "๐Ÿ’ฌ",
136
+ },
137
+ {
138
+ count: 9,
139
+ key: "masters",
140
+ label: "Master deliverables",
141
+ mark: "๐Ÿ“ฆ",
142
+ },
143
+ {
144
+ count: 5,
145
+ key: "cleanup",
146
+ label: "Account cleanup",
147
+ mark: "๐Ÿงน",
148
+ },
149
+ ]
150
+
151
+ const meta = {
152
+ title: "Components/Controls/DropRail",
153
+ component: DropRail,
154
+ parameters: { layout: "padded" },
155
+ args: {
156
+ isOpen: true,
157
+ label: "Move to which project",
158
+ onDismiss: () => {},
159
+ onPick: () => {},
160
+ targets: STUDIO_TARGETS,
161
+ },
162
+ } satisfies Meta<typeof DropRail>
163
+
164
+ export default meta
165
+
166
+ type Story = StoryObj<typeof meta>
167
+
168
+ /**
169
+ * The rail as it appears the moment a move starts. One chip is the
170
+ * group the card is already in โ€” visible, unofferable, and saying so
171
+ * to a screen reader rather than only by being dimmer.
172
+ */
173
+ export const Default: Story = {}
174
+
175
+ /**
176
+ * The case the component exists for. Thirty-four destinations do not
177
+ * fit in a menu without scrolling, and they do not fit on a display
178
+ * without the page moving โ€” but they wrap into four lines here and
179
+ * every one of them is one short movement away.
180
+ */
181
+ export const ManyTargets: Story = {
182
+ args: { targets: MANY_TARGETS },
183
+ }
184
+
185
+ /**
186
+ * Offerable, current and disabled, side by side. The three states
187
+ * differ by more than colour: the current chip carries visually
188
+ * hidden text and the disabled one carries `aria-disabled`.
189
+ */
190
+ export const AllStates: Story = {
191
+ render: (args) => (
192
+ <StoryGrid columns={1}>
193
+ <StoryCell align="stretch" label="Offerable">
194
+ <DropRail
195
+ {...args}
196
+ targets={[
197
+ {
198
+ count: 53,
199
+ key: "ferry",
200
+ label: "Ferry Docs",
201
+ mark: "๐Ÿ“„",
202
+ },
203
+ ]}
204
+ />
205
+ </StoryCell>
206
+
207
+ <StoryCell
208
+ align="stretch"
209
+ label="Where it already is"
210
+ >
211
+ <DropRail
212
+ {...args}
213
+ targets={[
214
+ {
215
+ count: 41,
216
+ isCurrent: true,
217
+ key: "atlas",
218
+ label: "Atlas Ingest",
219
+ mark: "๐Ÿ“ฅ",
220
+ },
221
+ ]}
222
+ />
223
+ </StoryCell>
224
+
225
+ <StoryCell align="stretch" label="Disabled">
226
+ <DropRail
227
+ {...args}
228
+ targets={[
229
+ {
230
+ count: 12,
231
+ isDisabled: true,
232
+ key: "signal",
233
+ label: "Signal Kitchen โ€” archived",
234
+ mark: "๐Ÿ—„๏ธ",
235
+ },
236
+ ]}
237
+ />
238
+ </StoryCell>
239
+ </StoryGrid>
240
+ ),
241
+ }
242
+
243
+ /**
244
+ * The whole gesture, wired to a host the way an app wires it.
245
+ *
246
+ * Press and hold a card's handle and drag onto a chip, or **tap** the
247
+ * handle and use the arrow keys and Enter. Both commit through the
248
+ * same `onPick`, which is the point: the rail does not know which one
249
+ * you used and the host does not have to say.
250
+ */
251
+ export const InAList: Story = {
252
+ render: (args) => {
253
+ const StoryHost = () => {
254
+ const [movingKey, setMovingKey] = useState<
255
+ string | null
256
+ >(null)
257
+
258
+ const [moved, setMoved] = useState<string | null>(
259
+ null,
260
+ )
261
+
262
+ const cards = [
263
+ {
264
+ key: "wall",
265
+ title: "Re-point the garden wall",
266
+ },
267
+ {
268
+ key: "filter",
269
+ title:
270
+ "Order the replacement extractor fan filter",
271
+ },
272
+ { key: "keys", title: "Label the spare keys" },
273
+ ]
274
+
275
+ return (
276
+ <div className="flex flex-col gap-2">
277
+ <DropRail
278
+ {...args}
279
+ isOpen={movingKey !== null}
280
+ onDismiss={() => {
281
+ setMovingKey(null)
282
+ }}
283
+ onPick={(targetKey) => {
284
+ const target = MANY_TARGETS.find(
285
+ (one) => one.key === targetKey,
286
+ )
287
+
288
+ setMoved(target?.label ?? targetKey)
289
+
290
+ setMovingKey(null)
291
+ }}
292
+ targets={MANY_TARGETS}
293
+ />
294
+
295
+ {cards.map((card) => (
296
+ <div
297
+ className="flex items-center gap-3 rounded-md border border-border-subtle bg-surface-raised p-3"
298
+ key={card.key}
299
+ >
300
+ <button
301
+ aria-label={`Move ${card.title} to another project`}
302
+ className="cursor-grab touch-none rounded-sm border border-border-default px-2 py-1 text-content-secondary text-xs hover:bg-surface-sunken"
303
+ onPointerDown={() => {
304
+ setMovingKey(card.key)
305
+ }}
306
+ type="button"
307
+ >
308
+ Move
309
+ </button>
310
+
311
+ <span className="text-content-primary text-sm">
312
+ {card.title}
313
+ </span>
314
+ </div>
315
+ ))}
316
+
317
+ <p className="text-content-secondary text-sm">
318
+ {moved === null
319
+ ? "Nothing moved yet."
320
+ : `Last move: ${moved}.`}
321
+ </p>
322
+ </div>
323
+ )
324
+ }
325
+
326
+ return <StoryHost />
327
+ },
328
+ }
@@ -0,0 +1,154 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import * as stories from "./DropRail.stories.tsx"
8
+
9
+ const { AllStates, Default, InAList, ManyTargets } =
10
+ composeStories(stories)
11
+
12
+ /**
13
+ * The pointer half of this component cannot be driven convincingly
14
+ * here, and `boardMove.ts` next door documents why: `userEvent` can
15
+ * dispatch `pointermove`, but the hit test is
16
+ * `document.elementFromPoint`, which needs a real layout at real
17
+ * coordinates. So these tests prove the parts a browser test can
18
+ * actually prove โ€” the roles, the three chip states, the keyboard
19
+ * path, and that a commit reports the destination out loud โ€” and
20
+ * the pointer path is left to the story a person drives.
21
+ *
22
+ * That split is deliberate rather than a gap being excused. The
23
+ * keyboard path is the one WCAG 2.5.7 requires, so it is the one
24
+ * that has to be red/green.
25
+ */
26
+
27
+ test("the rail is a listbox of destinations", async () => {
28
+ const { canvas } = await mountStory(Default)
29
+
30
+ const rail = canvas.getByRole("listbox", {
31
+ name: "Move to which project",
32
+ })
33
+
34
+ expect(rail).toBeInTheDocument()
35
+
36
+ expect(
37
+ canvas.getAllByRole("option").length,
38
+ ).toBeGreaterThan(1)
39
+ })
40
+
41
+ test("a closed rail renders nothing at all", async () => {
42
+ // `InAList` starts with no move in flight, which is the closed
43
+ // case as a host actually produces it. A rail that left an empty
44
+ // listbox behind would still be announced as one, and would still
45
+ // be a Tab stop on a page where nothing is being moved.
46
+ const { canvas } = await mountStory(InAList)
47
+
48
+ expect(canvas.queryByRole("listbox")).toBeNull()
49
+
50
+ expect(canvas.queryAllByRole("option")).toHaveLength(0)
51
+ })
52
+
53
+ test("the group a card is already in is shown but not offerable", async () => {
54
+ const { canvas } = await mountStory(Default)
55
+
56
+ const current = canvas.getByRole("option", {
57
+ name: /Atlas Ingest/,
58
+ })
59
+
60
+ expect(current).toHaveAttribute("aria-disabled", "true")
61
+
62
+ // Said out loud, not left to the dimmer colour. WCAG 1.4.1.
63
+ expect(current).toHaveTextContent("where it is now")
64
+ })
65
+
66
+ test("an archived destination is refused", async () => {
67
+ const { canvas } = await mountStory(Default)
68
+
69
+ expect(
70
+ canvas.getByRole("option", {
71
+ name: /Signal Kitchen/,
72
+ }),
73
+ ).toHaveAttribute("aria-disabled", "true")
74
+ })
75
+
76
+ test("the arrow keys walk the rail and Enter commits", async () => {
77
+ const { canvas } = await mountStory(InAList)
78
+
79
+ await userEvent.click(
80
+ canvas.getByRole("button", {
81
+ name: /Move Re-point the garden wall/,
82
+ }),
83
+ )
84
+
85
+ const rail = await waitFor(() =>
86
+ canvas.getByRole("listbox", {
87
+ name: "Move to which project",
88
+ }),
89
+ )
90
+
91
+ expect(rail).toBeInTheDocument()
92
+
93
+ // The first offerable chip takes focus when a pointer is not
94
+ // driving, so the keyboard path starts somewhere real.
95
+ await waitFor(() => {
96
+ expect(
97
+ canvas.getByRole("option", { name: /Loom Fleet/ }),
98
+ ).toHaveAttribute("tabindex", "0")
99
+ })
100
+
101
+ await userEvent.keyboard("{ArrowRight}")
102
+
103
+ await userEvent.keyboard("{Enter}")
104
+
105
+ await waitFor(() => {
106
+ expect(
107
+ canvas.getByText(/Last move:/),
108
+ ).toBeInTheDocument()
109
+ })
110
+ })
111
+
112
+ test("Escape abandons the move and nothing is committed", async () => {
113
+ const { canvas } = await mountStory(InAList)
114
+
115
+ await userEvent.click(
116
+ canvas.getByRole("button", {
117
+ name: /Move Label the spare keys/,
118
+ }),
119
+ )
120
+
121
+ await waitFor(() =>
122
+ canvas.getByRole("listbox", {
123
+ name: "Move to which project",
124
+ }),
125
+ )
126
+
127
+ await userEvent.keyboard("{Escape}")
128
+
129
+ await waitFor(() => {
130
+ expect(
131
+ canvas.queryByRole("listbox"),
132
+ ).not.toBeInTheDocument()
133
+ })
134
+
135
+ expect(
136
+ canvas.getByText("Nothing moved yet."),
137
+ ).toBeInTheDocument()
138
+ })
139
+
140
+ test("every destination stays reachable at the size that made a menu wrong", async () => {
141
+ const { canvas } = await mountStory(ManyTargets)
142
+
143
+ // Fifteen here rather than the Backlog's thirty-four, but the
144
+ // property under test is the one that matters: no destination is
145
+ // behind a scroll, so every chip is in the accessibility tree at
146
+ // once.
147
+ expect(canvas.getAllByRole("option")).toHaveLength(15)
148
+ })
149
+
150
+ test("no axe violations in any state", async () => {
151
+ const { canvasElement } = await mountStory(AllStates)
152
+
153
+ await expectNoAxeViolations(canvasElement)
154
+ })