@charcuterie/ui 4.24.0 → 4.26.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 (63) hide show
  1. package/README.md +13 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts +43 -0
  3. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
  4. package/dist/CommandPalette/CommandPalette.js +53 -0
  5. package/dist/CommandPalette/CommandPalette.js.map +1 -0
  6. package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
  7. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
  8. package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
  9. package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
  10. package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
  11. package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
  12. package/dist/CommandPalette/CommandPaletteOption.js +72 -0
  13. package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
  14. package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
  15. package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
  16. package/dist/CommandPalette/CommandPalettePanel.js +168 -0
  17. package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
  18. package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
  19. package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
  20. package/dist/CommandPalette/commandPaletteSections.js +94 -0
  21. package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
  22. package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
  23. package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
  24. package/dist/CommandPalette/useCommandPalette.js +40 -0
  25. package/dist/CommandPalette/useCommandPalette.js.map +1 -0
  26. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
  27. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
  28. package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
  29. package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
  30. package/dist/GroupedList/GroupedList.d.ts +78 -1
  31. package/dist/GroupedList/GroupedList.d.ts.map +1 -1
  32. package/dist/GroupedList/GroupedList.js +14 -0
  33. package/dist/GroupedList/GroupedList.js.map +1 -1
  34. package/dist/GroupedList/GroupedListRow.d.ts +17 -6
  35. package/dist/GroupedList/GroupedListRow.d.ts.map +1 -1
  36. package/dist/GroupedList/GroupedListRow.js +75 -8
  37. package/dist/GroupedList/GroupedListRow.js.map +1 -1
  38. package/dist/index.d.ts +7 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/styles.css +36 -4
  43. package/package.json +2 -2
  44. package/src/CommandPalette/CommandPalette.mdx +129 -0
  45. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  46. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  47. package/src/CommandPalette/CommandPalette.tsx +91 -0
  48. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  49. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  50. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  51. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  52. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  53. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  54. package/src/CommandPalette/useCommandPalette.ts +75 -0
  55. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  56. package/src/GroupedList/GroupedList.mdx +42 -1
  57. package/src/GroupedList/GroupedList.stories.tsx +252 -2
  58. package/src/GroupedList/GroupedList.test.tsx +391 -0
  59. package/src/GroupedList/GroupedList.tsx +78 -1
  60. package/src/GroupedList/GroupedListRow.tsx +135 -10
  61. package/src/index.ts +14 -0
  62. package/src/sourceRules.test.ts +2 -1
  63. package/src/styles.css +36 -4
@@ -9,6 +9,7 @@ import {
9
9
  within,
10
10
  } from "storybook/test"
11
11
  import { test } from "vitest"
12
+ import { userEvent as browserUserEvent } from "vitest/browser"
12
13
 
13
14
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
14
15
  import { mountStory } from "../mountStory.testHelpers.ts"
@@ -25,6 +26,8 @@ const {
25
26
  NarrowView,
26
27
  Playground,
27
28
  Responsive,
29
+ RowStates,
30
+ SelectableRows,
28
31
  StickyHeadings,
29
32
  } = composeStories(stories)
30
33
 
@@ -388,3 +391,391 @@ test("a stuck heading's background is the surface its host names, and surface-ba
388
391
  ).backgroundColor,
389
392
  )
390
393
  })
394
+
395
+ /** The `<li>` a whole-row button stretches over. */
396
+ const rowOf = (button: HTMLElement) =>
397
+ button.closest("li") as HTMLElement
398
+
399
+ /** What the pointer would hit at the middle of an element's box. */
400
+ const elementAtCentreOf = (element: HTMLElement) => {
401
+ const box = element.getBoundingClientRect()
402
+
403
+ return document.elementFromPoint(
404
+ box.left + box.width / 2,
405
+ box.top + box.height / 2,
406
+ )
407
+ }
408
+
409
+ test("a row without onSelect draws no row button", async () => {
410
+ const { canvas } = await mountStory(Playground)
411
+
412
+ await expect(
413
+ canvas.queryByRole("button", { name: "Lobby" }),
414
+ ).toBeNull()
415
+
416
+ await expect(
417
+ canvas
418
+ .getAllByRole("button")
419
+ .map((button: HTMLElement) =>
420
+ button.getAttribute("aria-label"),
421
+ ),
422
+ ).toEqual([
423
+ "Open Lobby",
424
+ "Open Break Room",
425
+ "Open Welcome",
426
+ "Open Weather",
427
+ "Open Schedule",
428
+ "Open Test Pattern",
429
+ ])
430
+ })
431
+
432
+ test("onSelect makes the whole row one native button, named by selectLabel", async () => {
433
+ const { canvas, canvasElement } =
434
+ await mountStory(SelectableRows)
435
+
436
+ const rowButton = expectAgentDrivable(canvas, {
437
+ name: "Edit Water the ferns",
438
+ role: "button",
439
+ })
440
+
441
+ await expect(rowButton.tagName).toBe("BUTTON")
442
+
443
+ await expect(rowButton).toHaveAttribute("type", "button")
444
+
445
+ // Nothing is nested inside it: the row's text is a sibling drawn
446
+ // over it, so there is no interactive content inside a button.
447
+ await expect(rowButton.childElementCount).toBe(0)
448
+
449
+ const row = rowOf(rowButton)
450
+
451
+ const buttonBox = rowButton.getBoundingClientRect()
452
+
453
+ // Full-area: the button covers the row's whole padding box — all
454
+ // of it but the 1px divider between rows.
455
+ await expect(buttonBox.width).toBeCloseTo(
456
+ row.clientWidth,
457
+ 0,
458
+ )
459
+
460
+ await expect(buttonBox.height).toBeCloseTo(
461
+ row.clientHeight,
462
+ 0,
463
+ )
464
+
465
+ // A pointer on the label, the description, the cap line or the
466
+ // payout lands on the row's button.
467
+ for (const text of [
468
+ "Water the ferns",
469
+ "water-ferns",
470
+ "Up to 5 pts a day",
471
+ "5 pts",
472
+ ]) {
473
+ await expect(
474
+ elementAtCentreOf(within(row).getByText(text)),
475
+ ).toBe(rowButton)
476
+ }
477
+
478
+ await userEvent.click(rowButton)
479
+
480
+ await expect(
481
+ canvas.getByRole("status"),
482
+ ).toHaveTextContent("Opened Water the ferns")
483
+
484
+ await expectNoAxeViolations(canvasElement)
485
+ })
486
+
487
+ test("a row's action stays its own target and never selects the row", async () => {
488
+ const { canvas } = await mountStory(SelectableRows)
489
+
490
+ const deleteButton = expectAgentDrivable(canvas, {
491
+ name: "Delete Water the ferns",
492
+ role: "button",
493
+ })
494
+
495
+ const hit = elementAtCentreOf(deleteButton)
496
+
497
+ await expect(
498
+ hit === deleteButton || deleteButton.contains(hit),
499
+ ).toBe(true)
500
+
501
+ // A real click at the action's centre, with real hit-testing.
502
+ await browserUserEvent.click(deleteButton)
503
+
504
+ await expect(
505
+ canvas.getByRole("status"),
506
+ ).toHaveTextContent("Deleted Water the ferns")
507
+ })
508
+
509
+ test("Tab reaches each row's button, then its action, in reading order, and Enter and Space open the row", async () => {
510
+ const { canvas } = await mountStory(SelectableRows)
511
+
512
+ const order = [
513
+ "Edit Water the ferns",
514
+ "Delete Water the ferns",
515
+ "Edit Sort the recycling",
516
+ "Delete Sort the recycling",
517
+ "Edit Feed the goldfish",
518
+ "Delete Feed the goldfish",
519
+ ]
520
+
521
+ for (const name of order) {
522
+ await userEvent.tab()
523
+
524
+ await expect(
525
+ expectAgentDrivable(canvas, { name, role: "button" }),
526
+ ).toHaveFocus()
527
+ }
528
+
529
+ const muted = canvas.getByRole("button", {
530
+ name: "Edit Feed the goldfish",
531
+ })
532
+
533
+ muted.focus()
534
+
535
+ await userEvent.keyboard("{Enter}")
536
+
537
+ await expect(
538
+ canvas.getByRole("status"),
539
+ ).toHaveTextContent("Opened Feed the goldfish")
540
+
541
+ canvas
542
+ .getByRole("button", {
543
+ name: "Edit Sort the recycling",
544
+ })
545
+ .focus()
546
+
547
+ await userEvent.keyboard(" ")
548
+
549
+ await expect(
550
+ canvas.getByRole("status"),
551
+ ).toHaveTextContent("Opened Sort the recycling")
552
+ })
553
+
554
+ test("keyboard focus draws the focus ring inside the row's edge", async () => {
555
+ const { canvas } = await mountStory(SelectableRows)
556
+
557
+ // A real Tab, because only a trusted key press makes the browser
558
+ // match `:focus-visible`.
559
+ await browserUserEvent.tab()
560
+
561
+ const rowButton = expectAgentDrivable(canvas, {
562
+ name: "Edit Water the ferns",
563
+ role: "button",
564
+ })
565
+
566
+ await expect(rowButton).toHaveFocus()
567
+
568
+ const style = getComputedStyle(rowButton)
569
+
570
+ await expect(style.outlineStyle).toBe("solid")
571
+
572
+ // Inside the edge: the group's list clips its corners, and an
573
+ // outside ring would be cut off on the first and last row. One
574
+ // ring-width further in again, so it never shares pixels with the
575
+ // highlight ring that hugs the edge.
576
+ const ringWidth = Number.parseFloat(style.outlineWidth)
577
+
578
+ await expect(ringWidth).toBeGreaterThan(0)
579
+
580
+ await expect(
581
+ Number.parseFloat(style.outlineOffset),
582
+ ).toBeCloseTo(-2 * ringWidth, 1)
583
+ })
584
+
585
+ test("hover tints the row and rings its edge; holding the row presses it", async () => {
586
+ const { canvas } = await mountStory(SelectableRows)
587
+
588
+ const rowButton = expectAgentDrivable(canvas, {
589
+ name: "Edit Water the ferns",
590
+ role: "button",
591
+ })
592
+
593
+ const resting = getComputedStyle(rowButton)
594
+
595
+ const restingBackground = resting.backgroundColor
596
+
597
+ const restingShadow = resting.boxShadow
598
+
599
+ await browserUserEvent.hover(rowButton)
600
+
601
+ await waitFor(() => {
602
+ expect(
603
+ getComputedStyle(rowButton).backgroundColor,
604
+ ).not.toBe(restingBackground)
605
+ })
606
+
607
+ await expect(
608
+ getComputedStyle(rowButton).boxShadow,
609
+ ).not.toBe(restingShadow)
610
+
611
+ await browserUserEvent.unhover(rowButton)
612
+
613
+ // Holding Space keeps a focused button `:active`, which is a press
614
+ // without a pointer — and it presses only the row whose button it
615
+ // is.
616
+ rowButton.focus()
617
+
618
+ await browserUserEvent.keyboard("{ >}")
619
+
620
+ const row = rowOf(rowButton)
621
+
622
+ // Tailwind's `scale-*` writes the individual `scale` property,
623
+ // not `transform`.
624
+ await waitFor(() => {
625
+ expect(getComputedStyle(row).scale).toBe("0.99")
626
+ })
627
+
628
+ await expect(
629
+ getComputedStyle(
630
+ rowOf(
631
+ canvas.getByRole("button", {
632
+ name: "Edit Sort the recycling",
633
+ }),
634
+ ),
635
+ ).scale,
636
+ ).toBe("none")
637
+
638
+ await browserUserEvent.keyboard("{/ }")
639
+
640
+ await expect(
641
+ canvas.getByRole("status"),
642
+ ).toHaveTextContent("Opened Water the ferns")
643
+ })
644
+
645
+ test("a muted row is a sunken surface with a quieter label, and still clears axe", async () => {
646
+ const { canvas, canvasElement } =
647
+ await mountStory(SelectableRows)
648
+
649
+ const mutedRow = rowOf(
650
+ canvas.getByRole("button", {
651
+ name: "Edit Feed the goldfish",
652
+ }),
653
+ )
654
+
655
+ const activeRow = rowOf(
656
+ canvas.getByRole("button", {
657
+ name: "Edit Water the ferns",
658
+ }),
659
+ )
660
+
661
+ await expect(
662
+ getComputedStyle(mutedRow).backgroundColor,
663
+ ).not.toBe(getComputedStyle(activeRow).backgroundColor)
664
+
665
+ await expect(
666
+ getComputedStyle(
667
+ within(mutedRow).getByText("Feed the goldfish"),
668
+ ).color,
669
+ ).not.toBe(
670
+ getComputedStyle(
671
+ within(activeRow).getByText("Water the ferns"),
672
+ ).color,
673
+ )
674
+
675
+ // Muted is not disabled: the row still opens.
676
+ await expect(
677
+ canvas.getByRole("button", {
678
+ name: "Edit Feed the goldfish",
679
+ }),
680
+ ).toBeEnabled()
681
+
682
+ await expectNoAxeViolations(canvasElement)
683
+ })
684
+
685
+ test("a highlighted row is ringed, and the ring lets the pointer through", async () => {
686
+ const { canvas } = await mountStory(SelectableRows)
687
+
688
+ const rowButton = canvas.getByRole("button", {
689
+ name: "Edit Sort the recycling",
690
+ })
691
+
692
+ const row = rowOf(rowButton)
693
+
694
+ const ring = row.querySelector(
695
+ ".charcuterie-highlight",
696
+ ) as HTMLElement
697
+
698
+ await expect(ring).not.toBeNull()
699
+
700
+ await expect(ring).toHaveAttribute("aria-hidden", "true")
701
+
702
+ await expect(getComputedStyle(ring).boxShadow).toContain(
703
+ "inset",
704
+ )
705
+
706
+ await expect(
707
+ elementAtCentreOf(within(row).getByText("2 pts")),
708
+ ).toBe(rowButton)
709
+
710
+ // Only the row that asked for it.
711
+ await expect(
712
+ rowOf(
713
+ canvas.getByRole("button", {
714
+ name: "Edit Water the ferns",
715
+ }),
716
+ ).querySelector(".charcuterie-highlight"),
717
+ ).toBeNull()
718
+ })
719
+
720
+ test("the aside sits beside the text in a wide list and never pushes the row past its list", async () => {
721
+ const { canvas } = await mountStory(SelectableRows)
722
+
723
+ const list = expectAgentDrivable(canvas, {
724
+ name: "Team Orion",
725
+ role: "list",
726
+ })
727
+
728
+ await expect(list.scrollWidth).toBeLessThanOrEqual(
729
+ list.clientWidth,
730
+ )
731
+
732
+ const row = rowOf(
733
+ canvas.getByRole("button", {
734
+ name: "Edit Water the ferns",
735
+ }),
736
+ )
737
+
738
+ const label = within(row).getByText("Water the ferns")
739
+
740
+ const capLine = within(row).getByText("Up to 5 pts a day")
741
+
742
+ const payout = within(row).getByText("Per scan")
743
+
744
+ // 12rem of text, the payout and the action fit on one line well
745
+ // inside 40rem; below that the content decides.
746
+ if (list.clientWidth >= 640) {
747
+ await expect(
748
+ payout.getBoundingClientRect().top,
749
+ ).toBeLessThan(capLine.getBoundingClientRect().bottom)
750
+
751
+ await expect(
752
+ payout.getBoundingClientRect().left,
753
+ ).toBeGreaterThan(label.getBoundingClientRect().right)
754
+ } else {
755
+ await expect(
756
+ payout.getBoundingClientRect().top,
757
+ ).toBeGreaterThanOrEqual(
758
+ capLine.getBoundingClientRect().bottom,
759
+ )
760
+ }
761
+ })
762
+
763
+ test("every row state passes axe in both schemes", async () => {
764
+ const { canvas, canvasElement } =
765
+ await mountStory(RowStates)
766
+
767
+ expectAgentDrivable(canvas, {
768
+ name: "Highlight intents",
769
+ role: "list",
770
+ })
771
+
772
+ // The canvas paints the scheme's own page surface, so the headings
773
+ // are measured against the background they would really sit on.
774
+ canvasElement.classList.add("bg-surface-base")
775
+
776
+ for (const scheme of ["light", "dark"]) {
777
+ canvasElement.setAttribute("data-scheme", scheme)
778
+
779
+ await expectNoAxeViolations(canvasElement)
780
+ }
781
+ })
@@ -1,4 +1,5 @@
1
1
  import { useUniqueId } from "@charcuterie/logic"
2
+ import type { IntentName } from "@charcuterie/tokens"
2
3
  import type { ReactNode } from "react"
3
4
 
4
5
  import { toClassName } from "../toClassName.ts"
@@ -18,24 +19,86 @@ export type GroupedListItem = {
18
19
  * **Name a repeated action after its row.** Ten rows of "Open" are
19
20
  * ten buttons an agent cannot tell apart; `aria-label="Open Lobby"`
20
21
  * on each is one `getByRole` away.
22
+ *
23
+ * In a row with `onSelect` these stay their own targets: they sit
24
+ * above the whole-row button, so pressing one never selects the
25
+ * row.
21
26
  */
22
27
  actions?: ReactNode
28
+ /**
29
+ * A figure that belongs beside the text rather than under it — a
30
+ * payout, a total, a schedule. It sits between the text and the
31
+ * actions in a wide list and drops under the text in a narrow one,
32
+ * the same content-decided wrap the actions use: it asks for at
33
+ * least 11rem beside the text column's 12rem. Never interactive:
34
+ * in a row with `onSelect` the whole-row button sits over it.
35
+ */
36
+ aside?: ReactNode
23
37
  /** `Badge`s, drawn after the label and wrapping onto their own line when they run out of room. */
24
38
  badges?: ReactNode
25
39
  /**
26
40
  * Anything richer than one line of help — a payout, a rule list.
27
41
  * Drawn under the description, inside the text column, and never
28
- * truncated.
42
+ * truncated. Never interactive in a row with `onSelect`; a control
43
+ * belongs in `actions`.
29
44
  */
30
45
  content?: ReactNode
31
46
  /** One muted line under the label: a path, a key, a count. Truncated like the label. */
32
47
  description?: string
48
+ /**
49
+ * The intent `isHighlighted` rings the row in. `accent` by default;
50
+ * an app whose highlight means "this just paid out" passes
51
+ * `success`.
52
+ */
53
+ highlightIntent?: IntentName
54
+ /**
55
+ * `true` rings the row and pulses the ring twice — "this one just
56
+ * changed", for a row a scan, a save or an arrival has touched.
57
+ * The ring stays while the flag does, so the app decides how long
58
+ * it lasts, usually by clearing it on a timer.
59
+ *
60
+ * **To pulse a row that is already highlighted again, change its
61
+ * `key`.** A new key is a new row, and a new row starts the pulse
62
+ * from the beginning; `` `${id}-${sequence}` `` is the usual shape.
63
+ *
64
+ * Under `prefers-reduced-motion` and on ePaper the ring does not
65
+ * pulse; it is simply there.
66
+ */
67
+ isHighlighted?: boolean
68
+ /**
69
+ * `true` draws the row receded — a sunken surface and a quieter
70
+ * label — for an entry that exists but is switched off. It stays
71
+ * fully operable and every colour on it still clears AA, which is
72
+ * why it is a surface and a text role rather than an opacity: a
73
+ * faded row is not a disabled one, and only a disabled control may
74
+ * drop below the contrast floor. Say WHY in a badge too ("off").
75
+ */
76
+ isMuted?: boolean
33
77
  key: string
34
78
  /**
35
79
  * Plain text, because it is what the row is called — the thing a
36
80
  * search matched and the text a clipped row's tooltip repeats.
37
81
  */
38
82
  label: string
83
+ /**
84
+ * Makes the whole row one native `<button>` — "open this one" for a
85
+ * row whose primary action is the row itself. Hover tints the row
86
+ * and rings its edge, a press sinks it (with motion only when the
87
+ * user has not asked for less), and keyboard focus draws the focus
88
+ * ring inside the row's edge. `actions` stay separate targets
89
+ * above it.
90
+ *
91
+ * The button is drawn UNDER the row's text and the text lets the
92
+ * pointer through, so nothing interactive is ever nested inside a
93
+ * button: keep `badges`, `content` and `aside` free of controls.
94
+ */
95
+ onSelect?: () => void
96
+ /**
97
+ * The whole-row button's accessible name. Defaults to `label`.
98
+ * Name the verb when there is one — `"Edit Water the ferns"` —
99
+ * the same rule as a repeated action.
100
+ */
101
+ selectLabel?: string
39
102
  }
40
103
 
41
104
  export type GroupedListGroup = {
@@ -148,6 +211,20 @@ const HEADING_TAG: Record<
148
211
  * know that surface, so `isHeadingSticky` reads it from a custom
149
212
  * property the host can set, falling back to `surface-base`.
150
213
  *
214
+ * ### A row can be the target
215
+ *
216
+ * tally-marks' card list opens a card's editor from anywhere on the
217
+ * card, and the owner asked for that to feel like an app: hover,
218
+ * press, focus, touch. `onSelect` is that, as one real `<button>`
219
+ * stretched under the row's text — not a click handler on an
220
+ * `<li>`, which no keyboard reaches and no screen reader announces.
221
+ * The text lets the pointer through to it; the actions sit above it
222
+ * and keep their own targets, so "Delete" never opens the editor.
223
+ *
224
+ * A row can also be muted (switched off but still there) and
225
+ * highlighted (just touched by a scan or a save) — the two other
226
+ * per-row states the same list drew by hand.
227
+ *
151
228
  * ### Not collapsible
152
229
  *
153
230
  * A group you can close is `Accordion`. This is for a list that is