@charcuterie/ui 4.24.0 → 4.25.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.
@@ -1,6 +1,6 @@
1
1
  import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
2
  import type { Meta, StoryObj } from "@storybook/react"
3
- import type { CSSProperties } from "react"
3
+ import { type CSSProperties, useState } from "react"
4
4
 
5
5
  import { Badge } from "../Badge/Badge.tsx"
6
6
  import { Button } from "../Button/Button.tsx"
@@ -10,7 +10,11 @@ import {
10
10
  StoryGrid,
11
11
  } from "../board.storyHelpers.tsx"
12
12
  import { EmptyState } from "../EmptyState/EmptyState.tsx"
13
- import type { GroupedListGroup } from "./GroupedList.tsx"
13
+ import { ValuePairs } from "../ValuePairs/ValuePairs.tsx"
14
+ import type {
15
+ GroupedListGroup,
16
+ GroupedListItem,
17
+ } from "./GroupedList.tsx"
14
18
  import { GroupedList } from "./GroupedList.tsx"
15
19
  import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts"
16
20
 
@@ -183,6 +187,142 @@ const CHORE_GROUPS: GroupedListGroup[] = [
183
187
  },
184
188
  ]
185
189
 
190
+ /**
191
+ * Fixture data only. A chore card whose whole row opens its editor:
192
+ * the shape tally-marks' Rows view draws, with the payout beside the
193
+ * text and the daily cap under it.
194
+ */
195
+ type ChoreCard = {
196
+ isActive: boolean
197
+ isHighlighted?: boolean
198
+ key: string
199
+ label: string
200
+ maxPerDay: number
201
+ points: number
202
+ }
203
+
204
+ const CHORE_CARDS: ChoreCard[] = [
205
+ {
206
+ isActive: true,
207
+ key: "water-ferns",
208
+ label: "Water the ferns",
209
+ maxPerDay: 5,
210
+ points: 5,
211
+ },
212
+ {
213
+ isActive: true,
214
+ isHighlighted: true,
215
+ key: "sort-recycling",
216
+ label: "Sort the recycling",
217
+ maxPerDay: 6,
218
+ points: 2,
219
+ },
220
+ {
221
+ isActive: false,
222
+ key: "feed-goldfish",
223
+ label: "Feed the goldfish",
224
+ maxPerDay: 2,
225
+ points: 2,
226
+ },
227
+ ]
228
+
229
+ /**
230
+ * One chore card as a row. `onSelect` opens it; the action beside it
231
+ * is a separate target. A switched-off card is muted and says so in
232
+ * a badge.
233
+ */
234
+ const toChoreRow = (
235
+ card: ChoreCard,
236
+ {
237
+ onDelete,
238
+ onSelect,
239
+ }: {
240
+ onDelete?: (label: string) => void
241
+ onSelect?: (label: string) => void
242
+ } = {},
243
+ ): GroupedListItem => ({
244
+ actions: (
245
+ <Button
246
+ appearance="ghost"
247
+ aria-label={`Delete ${card.label}`}
248
+ intent="danger"
249
+ onClick={() => {
250
+ onDelete?.(card.label)
251
+ }}
252
+ size="sm"
253
+ >
254
+ Delete
255
+ </Button>
256
+ ),
257
+ aside: (
258
+ <ValuePairs
259
+ items={[
260
+ {
261
+ id: "scan",
262
+ label: "Per scan",
263
+ value: `${card.points} pts`,
264
+ },
265
+ ]}
266
+ label="Payout"
267
+ />
268
+ ),
269
+ badges: card.isActive ? undefined : (
270
+ <Badge appearance="soft" size="sm">
271
+ off
272
+ </Badge>
273
+ ),
274
+ content: `Up to ${card.maxPerDay} pts a day`,
275
+ description: card.key,
276
+ highlightIntent: "success",
277
+ isHighlighted: card.isHighlighted,
278
+ isMuted: !card.isActive,
279
+ key: card.key,
280
+ label: card.label,
281
+ onSelect: () => {
282
+ onSelect?.(card.label)
283
+ },
284
+ selectLabel: `Edit ${card.label}`,
285
+ })
286
+
287
+ const toChoreGroups = (
288
+ handlers?: Parameters<typeof toChoreRow>[1],
289
+ ): GroupedListGroup[] => [
290
+ {
291
+ heading: "Team Orion",
292
+ items: CHORE_CARDS.map((card) =>
293
+ toChoreRow(card, handlers),
294
+ ),
295
+ key: "orion",
296
+ summary: "3 cards · up to 11 pts a day",
297
+ },
298
+ ]
299
+
300
+ /**
301
+ * Fixture data only. The same plain row in each highlight intent.
302
+ */
303
+ const HIGHLIGHT_INTENT_GROUPS: GroupedListGroup[] = [
304
+ {
305
+ heading: "Highlight intents",
306
+ items: (
307
+ [
308
+ "accent",
309
+ "success",
310
+ "info",
311
+ "warning",
312
+ "danger",
313
+ "neutral",
314
+ ] as const
315
+ ).map((intent) => ({
316
+ description: `highlightIntent="${intent}"`,
317
+ highlightIntent: intent,
318
+ isHighlighted: true,
319
+ key: intent,
320
+ label: `Highlighted ${intent}`,
321
+ })),
322
+ key: "intents",
323
+ },
324
+ ]
325
+
186
326
  const LONG_GROUPS: GroupedListGroup[] = [
187
327
  {
188
328
  heading:
@@ -524,6 +664,116 @@ export const StickyHeadings: Story = {
524
664
  ),
525
665
  }
526
666
 
667
+ /**
668
+ * A row whose whole surface is its primary action. `onSelect` draws
669
+ * one native button under the row's text, named by `selectLabel`;
670
+ * the Delete action sits above it as its own target. The status
671
+ * line under the list says which one a click reached.
672
+ *
673
+ * The second card is highlighted (a scan just paid it), and the
674
+ * third is switched off, so it is muted.
675
+ */
676
+ export const SelectableRows: Story = {
677
+ render: function SelectableRowsStory() {
678
+ const [lastEvent, setLastEvent] = useState(
679
+ "Nothing chosen yet",
680
+ )
681
+
682
+ return (
683
+ <div className="flex flex-col gap-3">
684
+ <GroupedList
685
+ groups={toChoreGroups({
686
+ onDelete: (label) => {
687
+ setLastEvent(`Deleted ${label}`)
688
+ },
689
+ onSelect: (label) => {
690
+ setLastEvent(`Opened ${label}`)
691
+ },
692
+ })}
693
+ />
694
+
695
+ <p
696
+ className="m-0 text-content-secondary text-sm"
697
+ role="status"
698
+ >
699
+ {lastEvent}
700
+ </p>
701
+ </div>
702
+ )
703
+ },
704
+ }
705
+
706
+ /**
707
+ * Every per-row state at once: hover, press and keyboard focus on
708
+ * the whole-row button (forced by the pseudo-states addon, the only
709
+ * way to review them in a static board), a muted row, and a
710
+ * highlight in each intent. The highlight's pulse is frozen here, so
711
+ * this is also its static, reduced-motion look.
712
+ */
713
+ export const RowStates: Story = {
714
+ parameters: {
715
+ pseudo: {
716
+ active: ["#active-row li:first-child > button"],
717
+ focusVisible: ["#focus-row li:first-child > button"],
718
+ hover: ["#hover-row li:first-child > button"],
719
+ },
720
+ },
721
+ render: () => (
722
+ <StoryGrid columns={2}>
723
+ {(
724
+ [
725
+ ["resting", "resting-row"],
726
+ ["hover (forced)", "hover-row"],
727
+ ["active (forced)", "active-row"],
728
+ ["focus-visible (forced)", "focus-row"],
729
+ ] as const
730
+ ).map(([label, id]) => (
731
+ <StoryCell align="stretch" key={id} label={label}>
732
+ <div id={id}>
733
+ <GroupedList
734
+ groups={[
735
+ {
736
+ heading: `Team Orion, ${label}`,
737
+ items: CHORE_CARDS.slice(0, 1).map(
738
+ (card) => toChoreRow(card),
739
+ ),
740
+ key: id,
741
+ },
742
+ ]}
743
+ size="sm"
744
+ />
745
+ </div>
746
+ </StoryCell>
747
+ ))}
748
+
749
+ <StoryCell align="stretch" label="isMuted">
750
+ <GroupedList
751
+ groups={[
752
+ {
753
+ heading: "Switched off",
754
+ items: [
755
+ toChoreRow(CHORE_CARDS[2] as ChoreCard),
756
+ ],
757
+ key: "muted",
758
+ },
759
+ ]}
760
+ size="sm"
761
+ />
762
+ </StoryCell>
763
+
764
+ <StoryCell
765
+ align="stretch"
766
+ label="isHighlighted, each highlightIntent"
767
+ >
768
+ <GroupedList
769
+ groups={HIGHLIGHT_INTENT_GROUPS}
770
+ size="sm"
771
+ />
772
+ </StoryCell>
773
+ </StoryGrid>
774
+ ),
775
+ }
776
+
527
777
  /**
528
778
  * Light and dark side by side, each panel scoped with its own
529
779
  * `data-scheme`, so both read at once whatever the toolbar says.
@@ -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
+ })