@charcuterie/ui 3.34.0 → 4.1.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 (87) hide show
  1. package/dist/ActionTiles/ActionTiles.d.ts +66 -8
  2. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
  3. package/dist/ActionTiles/ActionTiles.js +88 -11
  4. package/dist/ActionTiles/ActionTiles.js.map +1 -1
  5. package/dist/Main/Main.d.ts +18 -19
  6. package/dist/Main/Main.d.ts.map +1 -1
  7. package/dist/Main/Main.js +21 -10
  8. package/dist/Main/Main.js.map +1 -1
  9. package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
  10. package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
  11. package/dist/Main/ScrollMemoryProvider.js +30 -0
  12. package/dist/Main/ScrollMemoryProvider.js.map +1 -0
  13. package/dist/Main/scrollMemory.d.ts +54 -28
  14. package/dist/Main/scrollMemory.d.ts.map +1 -1
  15. package/dist/Main/scrollMemory.js +81 -40
  16. package/dist/Main/scrollMemory.js.map +1 -1
  17. package/dist/Main/scrollMemoryContext.d.ts +18 -0
  18. package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
  19. package/dist/Main/scrollMemoryContext.js +18 -0
  20. package/dist/Main/scrollMemoryContext.js.map +1 -0
  21. package/dist/Main/useScrollMemory.d.ts +20 -18
  22. package/dist/Main/useScrollMemory.d.ts.map +1 -1
  23. package/dist/Main/useScrollMemory.js +69 -42
  24. package/dist/Main/useScrollMemory.js.map +1 -1
  25. package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
  26. package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
  27. package/dist/PortraitTiles/PortraitAvatar.js +29 -0
  28. package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
  29. package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
  30. package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
  31. package/dist/PortraitTiles/PortraitTiles.js +263 -0
  32. package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
  33. package/dist/PortraitTiles/portraitColour.d.ts +49 -0
  34. package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
  35. package/dist/PortraitTiles/portraitColour.js +55 -0
  36. package/dist/PortraitTiles/portraitColour.js.map +1 -0
  37. package/dist/categoricalStyles.d.ts +27 -0
  38. package/dist/categoricalStyles.d.ts.map +1 -1
  39. package/dist/categoricalStyles.js +49 -0
  40. package/dist/categoricalStyles.js.map +1 -1
  41. package/dist/index.d.ts +7 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
  46. package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
  47. package/dist/reactRouter/ReactRouterAdapter.js +50 -0
  48. package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
  49. package/dist/reactRouter/index.d.ts +14 -8
  50. package/dist/reactRouter/index.d.ts.map +1 -1
  51. package/dist/reactRouter/index.js +13 -8
  52. package/dist/reactRouter/index.js.map +1 -1
  53. package/dist/tileStyles.d.ts +15 -0
  54. package/dist/tileStyles.d.ts.map +1 -1
  55. package/dist/tileStyles.js +26 -1
  56. package/dist/tileStyles.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/ActionTiles/ActionTiles.mdx +64 -6
  59. package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
  60. package/src/ActionTiles/ActionTiles.test.tsx +205 -5
  61. package/src/ActionTiles/ActionTiles.tsx +274 -110
  62. package/src/Main/Main.mdx +35 -7
  63. package/src/Main/Main.stories.tsx +129 -64
  64. package/src/Main/Main.test.tsx +58 -0
  65. package/src/Main/Main.tsx +20 -20
  66. package/src/Main/ScrollMemoryProvider.tsx +48 -0
  67. package/src/Main/scrollMemory.test.ts +70 -16
  68. package/src/Main/scrollMemory.ts +98 -40
  69. package/src/Main/scrollMemoryContext.ts +21 -0
  70. package/src/Main/useScrollMemory.ts +83 -43
  71. package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
  72. package/src/PortraitTiles/PortraitTiles.mdx +153 -0
  73. package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
  74. package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
  75. package/src/PortraitTiles/PortraitTiles.tsx +577 -0
  76. package/src/PortraitTiles/portraitColour.test.ts +39 -0
  77. package/src/PortraitTiles/portraitColour.ts +67 -0
  78. package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
  79. package/src/RouterLink/RouterLink.mdx +53 -16
  80. package/src/categoricalStyles.ts +57 -0
  81. package/src/index.ts +12 -0
  82. package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
  83. package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
  84. package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
  85. package/src/reactRouter/index.ts +14 -8
  86. package/src/sourceRules.test.ts +19 -3
  87. package/src/tileStyles.ts +30 -1
@@ -8,9 +8,11 @@ import {
8
8
  StorySection,
9
9
  } from "../board.storyHelpers.tsx"
10
10
  import {
11
+ ImageIcon,
11
12
  InboxIcon,
12
13
  PlayIcon,
13
14
  SearchIcon,
15
+ SettingsIcon,
14
16
  } from "../icons.storyHelpers.tsx"
15
17
  import { RadioGroup } from "../RadioGroup/RadioGroup.tsx"
16
18
  import type { ActionTileItem } from "./ActionTiles.tsx"
@@ -56,6 +58,43 @@ const START_ITEMS: ActionTileItem[] = [
56
58
  },
57
59
  ]
58
60
 
61
+ /**
62
+ * Enough tiles to see the palette walk. Ten hues, taken in order,
63
+ * and not one colour prop between them.
64
+ */
65
+ const LIBRARY_ITEMS: ActionTileItem[] = [
66
+ {
67
+ hint: "Feature films.",
68
+ icon: <PlayIcon />,
69
+ label: "Movies",
70
+ value: "movies",
71
+ },
72
+ {
73
+ hint: "Episodic television.",
74
+ icon: <InboxIcon />,
75
+ label: "Shows",
76
+ value: "shows",
77
+ },
78
+ {
79
+ hint: "Albums and tracks.",
80
+ icon: <SearchIcon />,
81
+ label: "Music",
82
+ value: "music",
83
+ },
84
+ {
85
+ hint: "Scanned pages by series.",
86
+ icon: <ImageIcon />,
87
+ label: "Comics",
88
+ value: "comics",
89
+ },
90
+ {
91
+ hint: "Cores and ROMs.",
92
+ icon: <SettingsIcon />,
93
+ label: "Games",
94
+ value: "games",
95
+ },
96
+ ]
97
+
59
98
  /**
60
99
  * A tile that carries an `href` is a real `<a href>`, so
61
100
  * middle-click, ctrl-click and "copy link address" all work. The
@@ -92,6 +131,7 @@ const meta = {
92
131
  // `args` from docgen, so an unstated default shows in the props
93
132
  // table with nothing selected in its control.
94
133
  args: {
134
+ accent: "auto",
95
135
  minTileInlineSize: 200,
96
136
  size: "md",
97
137
  },
@@ -243,8 +283,8 @@ export const AllStates: Story = {
243
283
  export const BesideTheRadioTile: Story = {
244
284
  args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
245
285
  render: (controlProps) => (
246
- <StorySection title="One box, two controls. Only the resting radio tile matches — a checked one wears the accent edge, which is the state ActionTiles has none of.">
247
- <StoryGrid columns={2}>
286
+ <StorySection title="One box, two controls. The neutral action tile matches the resting radio tile on every side; the coloured one differs only on the leading edge, by the room its bar takes.">
287
+ <StoryGrid columns={3}>
248
288
  <StoryCell
249
289
  align="stretch"
250
290
  label="ActionTiles — presses and is done"
@@ -252,6 +292,17 @@ export const BesideTheRadioTile: Story = {
252
292
  <ActionTiles {...controlProps} />
253
293
  </StoryCell>
254
294
 
295
+ <StoryCell
296
+ align="stretch"
297
+ label="ActionTiles accent='none' — the same box exactly"
298
+ >
299
+ <ActionTiles
300
+ {...controlProps}
301
+ accent="none"
302
+ label="Queue type, uncoloured"
303
+ />
304
+ </StoryCell>
305
+
255
306
  <StoryCell
256
307
  align="stretch"
257
308
  label="RadioGroup itemShape='tile' — holds a value"
@@ -309,3 +360,57 @@ export const Responsive: Story = {
309
360
  export const Interactive: Story = {
310
361
  args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
311
362
  }
363
+
364
+ /**
365
+ * The default: the set walks the ten-hue categorical palette in
366
+ * order, so ten tiles get ten colours and the call site passes no
367
+ * colour at all.
368
+ *
369
+ * The bar is `Card`'s accent-edge pseudo-element, which is why a
370
+ * tile and a card on one page are the same bar rather than two that
371
+ * nearly match, and why it follows the tile's corner instead of
372
+ * running past it and stopping square.
373
+ */
374
+ export const AutoHues: Story = {
375
+ args: { items: LIBRARY_ITEMS, label: "Pick a library" },
376
+ }
377
+
378
+ /**
379
+ * A tile that names its own `categorical` keeps that hue, and the
380
+ * rest go on walking the palette from their own positions — so
381
+ * naming one does not force naming all of them.
382
+ *
383
+ * Worth doing when the colour belongs to the THING rather than to
384
+ * the list: a destination that is hue 7 everywhere else in the app
385
+ * should not become hue 2 because a tile was inserted above it.
386
+ */
387
+ export const NamedHues: Story = {
388
+ args: {
389
+ items: [
390
+ { ...(LIBRARY_ITEMS[0] as ActionTileItem) },
391
+ {
392
+ ...(LIBRARY_ITEMS[1] as ActionTileItem),
393
+ categorical: 7,
394
+ },
395
+ { ...(LIBRARY_ITEMS[2] as ActionTileItem) },
396
+ ],
397
+ label: "Pick a library, with Shows pinned to hue 7",
398
+ },
399
+ }
400
+
401
+ /**
402
+ * `accent="none"` is the paint this component shipped with, kept as
403
+ * an opt-out rather than deleted.
404
+ *
405
+ * Reach for it when the tiles sit inside something already carrying
406
+ * a colour of its own — a coloured panel, a lane header — where a
407
+ * second palette is noise rather than information. Everything else
408
+ * about the tile is unchanged.
409
+ */
410
+ export const Neutral: Story = {
411
+ args: {
412
+ accent: "none",
413
+ items: START_ITEMS,
414
+ label: "Where to start",
415
+ },
416
+ }
@@ -9,14 +9,36 @@ import * as stories from "./ActionTiles.stories.tsx"
9
9
 
10
10
  const {
11
11
  AllStates,
12
+ AutoHues,
12
13
  BesideTheRadioTile,
13
14
  Default,
14
15
  Interactive,
15
16
  Links,
17
+ NamedHues,
18
+ Neutral,
16
19
  Responsive,
17
20
  WithIcons,
18
21
  } = composeStories(stories)
19
22
 
23
+ /**
24
+ * The accent-edge layer out of a computed `box-shadow`.
25
+ *
26
+ * Reading the whole string does not work: Tailwind's shadow chain
27
+ * always emits four EMPTY `rgba(0, 0, 0, 0) 0px 0px 0px 0px` layers
28
+ * for the ring and shadow slots nothing filled, so a naive
29
+ * "contains no transparent colour" assertion fails on a bar that is
30
+ * painted perfectly. The bar is the `inset` layer, and it is the
31
+ * only one.
32
+ *
33
+ * Returns `"none"` when there is no inset layer at all, which is
34
+ * what `accent="none"` must produce.
35
+ */
36
+ const getAccentEdgeLayer = (boxShadow: string) =>
37
+ boxShadow
38
+ .split(/,(?![^(]*\))/)
39
+ .map((layer) => layer.trim())
40
+ .find((layer) => layer.endsWith("inset")) ?? "none"
41
+
20
42
  test("it is a named group of pressable tiles", async () => {
21
43
  const { canvas, canvasElement } =
22
44
  await mountStory(Default)
@@ -187,14 +209,33 @@ test("a disabled link drops its href and stays announced as a link", async () =>
187
209
  * rather than trusted — the same discipline `ButtonLink.test.tsx`
188
210
  * applies to `Button`. Two copies of one string is a promise that
189
211
  * survives exactly one edit.
212
+ *
213
+ * The UNCOLOURED action tile is the one held to an exact match. A
214
+ * coloured one carries a bar its neighbour does not, and the room
215
+ * for that bar is a real difference on one side — asserted
216
+ * separately below, so "the boxes match" cannot quietly come to mean
217
+ * "the boxes match apart from whatever changed last".
190
218
  */
191
- test("an action tile is the same box as a resting radio tile", async () => {
219
+ test("an uncoloured action tile is the same box as a resting radio tile", async () => {
192
220
  const { canvas } = await mountStory(BesideTheRadioTile)
193
221
 
194
- const actionTile = expectAgentDrivable(canvas, {
195
- name: "Picks Choose titles yourself, then arrange them in priority and random lanes.",
196
- role: "button",
197
- })
222
+ // Scoped to the group rather than fetched by accessible name:
223
+ // the board draws the same two tiles three times, so a bare
224
+ // `getByRole("button", { name: "Picks …" })` is ambiguous.
225
+ const getFirstTile = (name: string) => {
226
+ const tile = expectAgentDrivable(canvas, {
227
+ name,
228
+ role: "group",
229
+ }).querySelector<HTMLElement>("button")
230
+
231
+ if (!tile) {
232
+ throw new Error(`${name} drew no tile`)
233
+ }
234
+
235
+ return tile
236
+ }
237
+
238
+ const actionTile = getFirstTile("Queue type, uncoloured")
198
239
 
199
240
  const radioGroup = expectAgentDrivable(canvas, {
200
241
  name: "Queue type, as radios",
@@ -227,6 +268,10 @@ test("an action tile is the same box as a resting radio tile", async () => {
227
268
  radio.paddingInlineStart,
228
269
  )
229
270
 
271
+ await expect(action.paddingInlineEnd).toBe(
272
+ radio.paddingInlineEnd,
273
+ )
274
+
230
275
  await expect(action.borderTopWidth).toBe(
231
276
  radio.borderTopWidth,
232
277
  )
@@ -253,6 +298,161 @@ test("an action tile is the same box as a resting radio tile", async () => {
253
298
  ).toBeGreaterThan(0)
254
299
  })
255
300
 
301
+ /**
302
+ * The bar is an OVERLAY — `Card`'s accent-edge pseudo-element, which
303
+ * is painted over the box and occupies nothing in it. A tile that
304
+ * did not widen its leading padding would draw the bar straight
305
+ * through the first letter of its own name, and nothing would report
306
+ * that: the pseudo-element is not in the DOM, so no query can miss
307
+ * it and axe has nothing to say about a glyph with a stripe on it.
308
+ *
309
+ * So the assertion is the difference itself, in computed pixels.
310
+ */
311
+ test("a coloured tile makes room for its bar, and only on the leading side", async () => {
312
+ const { canvas } = await mountStory(BesideTheRadioTile)
313
+
314
+ const getFirstTile = (name: string) => {
315
+ const tile = expectAgentDrivable(canvas, {
316
+ name,
317
+ role: "group",
318
+ }).querySelector<HTMLElement>("button")
319
+
320
+ if (!tile) {
321
+ throw new Error(`${name} drew no tile`)
322
+ }
323
+
324
+ return tile
325
+ }
326
+
327
+ const coloured = getComputedStyle(
328
+ getFirstTile("Queue type"),
329
+ )
330
+
331
+ const uncoloured = getComputedStyle(
332
+ getFirstTile("Queue type, uncoloured"),
333
+ )
334
+
335
+ await expect(
336
+ Number.parseFloat(coloured.paddingInlineStart),
337
+ ).toBeGreaterThan(
338
+ Number.parseFloat(uncoloured.paddingInlineStart),
339
+ )
340
+
341
+ // Every other side is untouched. The colour is a bar and a hue,
342
+ // not a second set of metrics.
343
+ await expect(coloured.paddingInlineEnd).toBe(
344
+ uncoloured.paddingInlineEnd,
345
+ )
346
+
347
+ await expect(coloured.paddingTop).toBe(
348
+ uncoloured.paddingTop,
349
+ )
350
+
351
+ await expect(coloured.paddingBottom).toBe(
352
+ uncoloured.paddingBottom,
353
+ )
354
+
355
+ await expect(coloured.borderTopLeftRadius).toBe(
356
+ uncoloured.borderTopLeftRadius,
357
+ )
358
+ })
359
+
360
+ /**
361
+ * Ten hues taken in order, which is what makes a five-tile set need
362
+ * no colour props at all.
363
+ *
364
+ * Asserted through the pseudo-element's own `box-shadow`, because
365
+ * that is where the bar actually lives — a class-name assertion
366
+ * passes while the paint is wrong, and a `--color-categorical-*`
367
+ * that had never existed would resolve to nothing, paint
368
+ * transparent, and satisfy every check that the element rendered.
369
+ */
370
+ test("a set walks the palette, and a named hue holds its place", async () => {
371
+ const { canvas } = await mountStory(AutoHues)
372
+
373
+ const group = expectAgentDrivable(canvas, {
374
+ name: "Pick a library",
375
+ role: "group",
376
+ })
377
+
378
+ const bars = Array.from(
379
+ group.querySelectorAll<HTMLElement>("button"),
380
+ ).map((tile) =>
381
+ getAccentEdgeLayer(
382
+ getComputedStyle(tile, "::before").boxShadow,
383
+ ),
384
+ )
385
+
386
+ await expect(bars).toHaveLength(5)
387
+
388
+ for (const bar of bars) {
389
+ // A token that resolved to nothing paints a transparent bar and
390
+ // passes every "did it render" check ever written.
391
+ await expect(bar).toMatch(/^rgb\(\d+, \d+, \d+\) /)
392
+ }
393
+
394
+ // Five tiles, five different colours.
395
+ await expect(new Set(bars).size).toBe(5)
396
+ })
397
+
398
+ test("a named hue survives a tile being inserted above it", async () => {
399
+ const { canvas } = await mountStory(NamedHues)
400
+
401
+ const pinned = expectAgentDrivable(canvas, {
402
+ name: "Shows Episodic television.",
403
+ role: "button",
404
+ })
405
+
406
+ const { canvas: autoCanvas } = await mountStory(AutoHues)
407
+
408
+ const seventh = Array.from(
409
+ expectAgentDrivable(autoCanvas, {
410
+ name: "Pick a library",
411
+ role: "group",
412
+ }).querySelectorAll<HTMLElement>("button"),
413
+ )[1]
414
+
415
+ if (!seventh) {
416
+ throw new Error("the auto-hue set drew no second tile")
417
+ }
418
+
419
+ // In the auto set this tile is position 1 and wears hue 2. Naming
420
+ // `categorical: 7` has to change it, or the prop does nothing and
421
+ // every test above still passes.
422
+ await expect(
423
+ getAccentEdgeLayer(
424
+ getComputedStyle(pinned, "::before").boxShadow,
425
+ ),
426
+ ).not.toBe(
427
+ getAccentEdgeLayer(
428
+ getComputedStyle(seventh, "::before").boxShadow,
429
+ ),
430
+ )
431
+ })
432
+
433
+ /**
434
+ * `accent="none"` is an opt-out, so it has to actually draw no bar
435
+ * — not a bar in a neutral colour, which would still be a stripe on
436
+ * a page that asked for none.
437
+ */
438
+ test("accent='none' draws no bar at all", async () => {
439
+ const { canvas, canvasElement } =
440
+ await mountStory(Neutral)
441
+
442
+ const tile = expectAgentDrivable(canvas, {
443
+ name: "New arrivals Everything that arrived since the last run.",
444
+ role: "button",
445
+ })
446
+
447
+ await expect(
448
+ getAccentEdgeLayer(
449
+ getComputedStyle(tile, "::before").boxShadow,
450
+ ),
451
+ ).toBe("none")
452
+
453
+ await expectNoAxeViolations(canvasElement)
454
+ })
455
+
256
456
  test("the column count comes from the container, not the window", async () => {
257
457
  const { canvas } = await mountStory(Responsive)
258
458