@charcuterie/ui 4.0.0 → 4.2.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 (54) hide show
  1. package/README.md +3 -2
  2. package/dist/ActionTiles/ActionTiles.d.ts +66 -8
  3. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
  4. package/dist/ActionTiles/ActionTiles.js +88 -11
  5. package/dist/ActionTiles/ActionTiles.js.map +1 -1
  6. package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
  7. package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
  8. package/dist/PortraitTiles/PortraitAvatar.js +29 -0
  9. package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
  10. package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
  11. package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
  12. package/dist/PortraitTiles/PortraitTiles.js +263 -0
  13. package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
  14. package/dist/PortraitTiles/portraitColour.d.ts +49 -0
  15. package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
  16. package/dist/PortraitTiles/portraitColour.js +55 -0
  17. package/dist/PortraitTiles/portraitColour.js.map +1 -0
  18. package/dist/RouterLink/UnstyledLink.d.ts +17 -0
  19. package/dist/RouterLink/UnstyledLink.d.ts.map +1 -0
  20. package/dist/RouterLink/UnstyledLink.js +24 -0
  21. package/dist/RouterLink/UnstyledLink.js.map +1 -0
  22. package/dist/categoricalStyles.d.ts +27 -0
  23. package/dist/categoricalStyles.d.ts.map +1 -1
  24. package/dist/categoricalStyles.js +49 -0
  25. package/dist/categoricalStyles.js.map +1 -1
  26. package/dist/index.d.ts +6 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/tileStyles.d.ts +15 -0
  31. package/dist/tileStyles.d.ts.map +1 -1
  32. package/dist/tileStyles.js +26 -1
  33. package/dist/tileStyles.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/ActionTiles/ActionTiles.mdx +82 -8
  36. package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
  37. package/src/ActionTiles/ActionTiles.test.tsx +205 -5
  38. package/src/ActionTiles/ActionTiles.tsx +274 -110
  39. package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
  40. package/src/PortraitTiles/PortraitTiles.mdx +153 -0
  41. package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
  42. package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
  43. package/src/PortraitTiles/PortraitTiles.tsx +577 -0
  44. package/src/PortraitTiles/portraitColour.test.ts +39 -0
  45. package/src/PortraitTiles/portraitColour.ts +67 -0
  46. package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
  47. package/src/RouterLink/RouterLink.mdx +25 -4
  48. package/src/RouterLink/UnstyledLink.stories.tsx +57 -0
  49. package/src/RouterLink/UnstyledLink.test.tsx +40 -0
  50. package/src/RouterLink/UnstyledLink.tsx +34 -0
  51. package/src/categoricalStyles.ts +57 -0
  52. package/src/index.ts +11 -0
  53. package/src/sourceRules.test.ts +17 -4
  54. package/src/tileStyles.ts +30 -1
@@ -0,0 +1,399 @@
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 { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./PortraitTiles.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ DataColours,
14
+ Default,
15
+ Interactive,
16
+ NamedHues,
17
+ Responsive,
18
+ WithPictures,
19
+ } = composeStories(stories)
20
+
21
+ test("it is a named group of pressable portraits", async () => {
22
+ const { canvas, canvasElement } =
23
+ await mountStory(Default)
24
+
25
+ const group = expectAgentDrivable(canvas, {
26
+ name: "Who's shopping?",
27
+ role: "group",
28
+ })
29
+
30
+ await expect(
31
+ group.querySelectorAll("button"),
32
+ ).toHaveLength(4)
33
+
34
+ // Nothing here is selected, and nothing claims to be — the same
35
+ // call `ActionTiles` makes. A picker that keeps a value is
36
+ // `RadioGroup itemShape="tile"`.
37
+ await expect(
38
+ group.querySelector(
39
+ "[aria-checked], [aria-pressed], [role='radio']",
40
+ ),
41
+ ).toBeNull()
42
+
43
+ await expectNoAxeViolations(canvasElement)
44
+ })
45
+
46
+ /**
47
+ * The number and its unit are both inside the tile, so both are
48
+ * computed into the accessible name. "1,240" on its own tells a
49
+ * screen-reader user nothing at all, and "Avery" alone loses the
50
+ * thing the portrait was being chosen by.
51
+ */
52
+ test("the name, the number and its unit are one accessible name", async () => {
53
+ const { canvas } = await mountStory(Default)
54
+
55
+ expectAgentDrivable(canvas, {
56
+ name: "Avery 1,240 points",
57
+ role: "button",
58
+ })
59
+ })
60
+
61
+ /**
62
+ * The avatar is decoration: the name it sits beside is inside the
63
+ * same tile, so an announced picture is the label read twice. This
64
+ * holds for the initials fallback and for a real `<img>`, which is
65
+ * why both are checked.
66
+ */
67
+ test("the face stays out of the accessible name", async () => {
68
+ const { canvas, canvasElement } =
69
+ await mountStory(WithPictures)
70
+
71
+ const group = expectAgentDrivable(canvas, {
72
+ name: "Who's shopping?",
73
+ role: "group",
74
+ })
75
+
76
+ for (const image of group.querySelectorAll("img")) {
77
+ await expect(image).toHaveAttribute("alt", "")
78
+ }
79
+
80
+ // Named by its words alone. An initial that joined the name would
81
+ // read "A Avery 1,240 points".
82
+ expectAgentDrivable(canvas, {
83
+ name: "Casey 2,015 points",
84
+ role: "button",
85
+ })
86
+
87
+ await expectNoAxeViolations(canvasElement)
88
+ })
89
+
90
+ /**
91
+ * A 404 is the case this shape cannot absorb: a torn hole where a
92
+ * face was, beside three that loaded. `onError` is the only signal a
93
+ * browser gives, and the fallback is the one the caller already
94
+ * supplied for "no picture".
95
+ *
96
+ * The story points a portrait at a file that does not exist, so this
97
+ * is the real failure rather than a mocked one.
98
+ */
99
+ test("a picture that fails to load falls back to the initials", async () => {
100
+ const { canvas } = await mountStory(WithPictures)
101
+
102
+ const group = expectAgentDrivable(canvas, {
103
+ name: "Who's shopping?",
104
+ role: "group",
105
+ })
106
+
107
+ await waitFor(async () => {
108
+ const broken = Array.from(
109
+ group.querySelectorAll<HTMLImageElement>("img"),
110
+ ).find((one) =>
111
+ one.src.includes("charcuterie-missing-portrait"),
112
+ )
113
+
114
+ // Gone from the DOM entirely, replaced by the letter — not left
115
+ // in place with a broken-image glyph in it.
116
+ await expect(broken).toBeUndefined()
117
+ })
118
+
119
+ const bailey = expectAgentDrivable(canvas, {
120
+ name: "Bailey 860 points",
121
+ role: "button",
122
+ })
123
+
124
+ await expect(bailey.textContent).toContain("B")
125
+ })
126
+
127
+ /**
128
+ * `layout="auto"` reads the container and never the window. Every
129
+ * panel in the board is the same window, so a difference between
130
+ * them can only have come from the container.
131
+ */
132
+ test("the arrangement comes from the container, not the window", async () => {
133
+ const { canvas } = await mountStory(Responsive)
134
+
135
+ const getTileDirection = (name: string) => {
136
+ const tile = expectAgentDrivable(canvas, {
137
+ name,
138
+ role: "group",
139
+ }).querySelector<HTMLElement>("button")
140
+
141
+ if (!tile) {
142
+ throw new Error(`${name} drew no portrait`)
143
+ }
144
+
145
+ return getComputedStyle(tile).flexDirection
146
+ }
147
+
148
+ // Below `cq-sm` there is no room to stack: a face beside a name is
149
+ // what fits in one narrow column.
150
+ await expect(
151
+ getTileDirection("Who's shopping at 15rem"),
152
+ ).toBe("row")
153
+
154
+ await expect(
155
+ getTileDirection("Who's shopping at 34rem"),
156
+ ).toBe("column")
157
+ })
158
+
159
+ test("a fixed layout skips the query", async () => {
160
+ const { canvas, canvasElement } =
161
+ await mountStory(AllVariants)
162
+
163
+ const getTileDirection = (name: string) => {
164
+ const tile = expectAgentDrivable(canvas, {
165
+ name,
166
+ role: "group",
167
+ }).querySelector<HTMLElement>("button")
168
+
169
+ if (!tile) {
170
+ throw new Error(`${name} drew no portrait`)
171
+ }
172
+
173
+ return getComputedStyle(tile).flexDirection
174
+ }
175
+
176
+ // Both cells are the same width. Only the prop differs, which is
177
+ // the point of having one.
178
+ await expect(
179
+ getTileDirection("Who's shopping, as rows"),
180
+ ).toBe("row")
181
+
182
+ await expect(
183
+ getTileDirection("Who's shopping, as columns"),
184
+ ).toBe("column")
185
+
186
+ await expectNoAxeViolations(canvasElement)
187
+ })
188
+
189
+ /**
190
+ * The hue has to reach the paint, and the assertion is a computed
191
+ * colour rather than a class name. A `--color-categorical-*` that
192
+ * had never existed resolves to nothing, paints transparent, and
193
+ * satisfies every check that the element rendered — which is exactly
194
+ * how a token name that did not exist shipped in Docket.
195
+ */
196
+ test("each portrait wears a different hue, and a named one holds it", async () => {
197
+ const { canvas } = await mountStory(Default)
198
+
199
+ const faces = Array.from(
200
+ expectAgentDrivable(canvas, {
201
+ name: "Who's shopping?",
202
+ role: "group",
203
+ }).querySelectorAll<HTMLElement>(
204
+ "button > *:first-child",
205
+ ),
206
+ ).map((face) => getComputedStyle(face).backgroundColor)
207
+
208
+ await expect(faces).toHaveLength(4)
209
+
210
+ for (const face of faces) {
211
+ await expect(face).not.toBe("rgba(0, 0, 0, 0)")
212
+ }
213
+
214
+ await expect(new Set(faces).size).toBe(4)
215
+
216
+ const { canvas: pinnedCanvas } =
217
+ await mountStory(NamedHues)
218
+
219
+ const pinned = Array.from(
220
+ expectAgentDrivable(pinnedCanvas, {
221
+ name: "Who's shopping, with pinned colours",
222
+ role: "group",
223
+ }).querySelectorAll<HTMLElement>(
224
+ "button > *:first-child",
225
+ ),
226
+ ).map((face) => getComputedStyle(face).backgroundColor)
227
+
228
+ // Avery is position 0 in both sets. Naming `categorical: 3` has to
229
+ // change its colour, or the prop does nothing and every assertion
230
+ // above still passes.
231
+ await expect(pinned[0]).not.toBe(faces[0])
232
+ })
233
+
234
+ /**
235
+ * The second colour arm, which no token covers: the hue arrives from
236
+ * data, lands in an inline custom property, and three separate
237
+ * classes read it back out. Every step of that is invisible to
238
+ * typecheck, to lint and to axe — the CSS either generated or it did
239
+ * not, and a portrait with no fill looks like a picture that failed
240
+ * to load.
241
+ */
242
+ test("a data colour reaches the fill exactly, and the initials flip to suit it", async () => {
243
+ const { canvas, canvasElement } =
244
+ await mountStory(DataColours)
245
+
246
+ const faces = Array.from(
247
+ expectAgentDrivable(canvas, {
248
+ name: "Who's shopping, coloured by their cards",
249
+ role: "group",
250
+ }).querySelectorAll<HTMLElement>(
251
+ "button > *:first-child",
252
+ ),
253
+ )
254
+
255
+ await expect(faces).toHaveLength(3)
256
+
257
+ // Exactly the stated colour. A palette hue would be some other
258
+ // triplet entirely, which is the whole point of the arm.
259
+ await expect(
260
+ faces.map(
261
+ (face) => getComputedStyle(face).backgroundColor,
262
+ ),
263
+ ).toEqual([
264
+ "rgb(143, 211, 244)",
265
+ "rgb(217, 210, 197)",
266
+ "rgb(59, 74, 107)",
267
+ ])
268
+
269
+ // The first two fills are pale and the third is dark, so the
270
+ // letters have to go the other way on the third. Nothing in the
271
+ // toolchain would have said otherwise.
272
+ await expect(
273
+ faces.map((face) => getComputedStyle(face).color),
274
+ ).toEqual([
275
+ "rgb(0, 0, 0)",
276
+ "rgb(0, 0, 0)",
277
+ "rgb(255, 255, 255)",
278
+ ])
279
+
280
+ await expectNoAxeViolations(canvasElement)
281
+ })
282
+
283
+ /**
284
+ * The number is the hue mixed toward the scheme's own text colour,
285
+ * not the hue itself — a `#C0C4CC` number on a near-white surface is
286
+ * the readability failure this arm gives up the palette's guarantee
287
+ * to allow.
288
+ */
289
+ test("the number is the data colour pulled toward readable, not the raw colour", async () => {
290
+ const { canvas } = await mountStory(DataColours)
291
+
292
+ const bailey = Array.from(
293
+ expectAgentDrivable(canvas, {
294
+ name: "Who's shopping, coloured by their cards",
295
+ role: "group",
296
+ }).querySelectorAll<HTMLButtonElement>("button"),
297
+ ).find((one) => one.textContent?.includes("Bailey"))
298
+
299
+ const stat = Array.from(
300
+ bailey?.querySelectorAll<HTMLElement>("span") ?? [],
301
+ ).find((one) => one.textContent === "860")
302
+
303
+ await expect(stat).toBeDefined()
304
+
305
+ const statColour = getComputedStyle(
306
+ stat as HTMLElement,
307
+ ).color
308
+
309
+ await expect(statColour).not.toBe("rgb(217, 210, 197)")
310
+
311
+ await expect(statColour).not.toBe("rgba(0, 0, 0, 0)")
312
+ })
313
+
314
+ test("every portrait is its own tab stop, and Enter and Space press it", async () => {
315
+ const { canvas } = await mountStory(Interactive)
316
+
317
+ const tiles = Array.from(
318
+ expectAgentDrivable(canvas, {
319
+ name: "Who's shopping?",
320
+ role: "group",
321
+ }).querySelectorAll<HTMLButtonElement>("button"),
322
+ )
323
+
324
+ await expect(
325
+ tiles.filter((one) => one.tabIndex === 0),
326
+ ).toHaveLength(2)
327
+
328
+ tiles[0]?.focus()
329
+
330
+ await userEvent.tab()
331
+
332
+ await waitFor(() => {
333
+ expect(tiles[1]).toHaveFocus()
334
+ })
335
+
336
+ await userEvent.keyboard("{Enter}")
337
+
338
+ await userEvent.keyboard(" ")
339
+ })
340
+
341
+ test("a disabled portrait cannot be pressed", async () => {
342
+ const { canvas, canvasElement } =
343
+ await mountStory(AllStates)
344
+
345
+ const bailey = Array.from(
346
+ expectAgentDrivable(canvas, {
347
+ name: "Who's shopping, with Bailey unavailable",
348
+ role: "group",
349
+ }).querySelectorAll<HTMLButtonElement>("button"),
350
+ ).find((one) => one.textContent?.includes("Bailey"))
351
+
352
+ await expect(bailey).toBeDisabled()
353
+
354
+ await expectNoAxeViolations(canvasElement)
355
+ })
356
+
357
+ test("an href portrait is a real anchor", async () => {
358
+ const { canvas, canvasElement } =
359
+ await mountStory(AllStates)
360
+
361
+ const avery = Array.from(
362
+ expectAgentDrivable(canvas, {
363
+ name: "Who's shopping, as links",
364
+ role: "group",
365
+ }).querySelectorAll<HTMLAnchorElement>("a"),
366
+ ).find((one) => one.textContent?.includes("Avery"))
367
+
368
+ // A real `<a href>`, so middle-click, ctrl-click, "open in a new
369
+ // tab" and "copy link address" come from the element rather than
370
+ // from the paint.
371
+ await expect(avery).toHaveAttribute("href", "/avery")
372
+
373
+ await expectNoAxeViolations(canvasElement)
374
+ })
375
+
376
+ /**
377
+ * The box is shared with the other tile shapes through
378
+ * `tileStyles.ts`. Two tile sets on one page have to be the same
379
+ * card, and "identical" spelled as two copies of one string is a
380
+ * promise that survives exactly one edit.
381
+ */
382
+ test("a portrait is the same box as an action tile", async () => {
383
+ const { canvas } = await mountStory(Default)
384
+
385
+ const portrait = expectAgentDrivable(canvas, {
386
+ name: "Avery 1,240 points",
387
+ role: "button",
388
+ })
389
+
390
+ const style = getComputedStyle(portrait)
391
+
392
+ await expect(style.borderTopWidth).toBe("1px")
393
+
394
+ await expect(style.borderTopLeftRadius).not.toBe("0px")
395
+
396
+ await expect(
397
+ Number.parseFloat(style.paddingTop),
398
+ ).toBeGreaterThan(0)
399
+ })