@charcuterie/ui 4.18.2 → 4.19.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.
@@ -11,8 +11,15 @@ import {
11
11
  } from "../viewport.testHelpers.ts"
12
12
  import * as stories from "./DataTable.stories.tsx"
13
13
 
14
- const { AllVariants, Playground, Interactive, Responsive } =
15
- composeStories(stories)
14
+ const {
15
+ AllVariants,
16
+ Playground,
17
+ Interactive,
18
+ Responsive,
19
+ LongColumnLabels,
20
+ DenseComparison,
21
+ StackedList,
22
+ } = composeStories(stories)
16
23
 
17
24
  /**
18
25
  * The `<caption>` is the table's accessible name. Without one a
@@ -384,3 +391,119 @@ test("an empty table keeps its headers", async () => {
384
391
  ?.querySelectorAll("thead th"),
385
392
  ).toHaveLength(5)
386
393
  })
394
+
395
+ test("stacked cells show complete long column names within a narrow container", async () => {
396
+ const { canvas, canvasElement } = await mountStory(
397
+ LongColumnLabels,
398
+ )
399
+ expectAgentDrivable(canvas, {
400
+ role: "table",
401
+ name: "Tasks with complete column labels",
402
+ })
403
+ const labels = Array.from(
404
+ canvasElement.querySelectorAll<HTMLElement>(
405
+ 'tbody td > span[aria-hidden="true"]',
406
+ ),
407
+ )
408
+ await expect(labels).toHaveLength(6)
409
+ for (const label of labels) {
410
+ await expect(label).toBeVisible()
411
+ await expect(label.scrollWidth).toBeLessThanOrEqual(
412
+ label.clientWidth + 1,
413
+ )
414
+ await expect(label.scrollHeight).toBeLessThanOrEqual(
415
+ label.clientHeight + 1,
416
+ )
417
+ await expect(
418
+ getComputedStyle(label).textOverflow,
419
+ ).not.toBe("ellipsis")
420
+ }
421
+ await expect(
422
+ labels[0].getBoundingClientRect().height,
423
+ ).toBeGreaterThan(
424
+ Number.parseFloat(
425
+ getComputedStyle(labels[0]).lineHeight,
426
+ ),
427
+ )
428
+ await expectNoAxeViolations(canvasElement)
429
+ })
430
+
431
+ test("dense comparisons reflow by container width while retaining every column", async () => {
432
+ await setViewport(DESKTOP)
433
+ const { canvas, canvasElement } =
434
+ await mountStory(DenseComparison)
435
+ const narrow = expectAgentDrivable(canvas, {
436
+ role: "table",
437
+ name: "Dense comparison at 60rem",
438
+ })
439
+ const wide = expectAgentDrivable(canvas, {
440
+ role: "table",
441
+ name: "Dense comparison at 75rem",
442
+ })
443
+ await expect(
444
+ getComputedStyle(
445
+ narrow.querySelector("tbody td") as HTMLElement,
446
+ ).display,
447
+ ).toBe("flex")
448
+ await expect(
449
+ getComputedStyle(
450
+ wide.querySelector("tbody td") as HTMLElement,
451
+ ).display,
452
+ ).toBe("table-cell")
453
+ for (const header of wide.querySelectorAll<HTMLElement>(
454
+ "thead th button",
455
+ )) {
456
+ await expect(
457
+ getComputedStyle(header).overflowWrap,
458
+ ).toBe("normal")
459
+ }
460
+ for (const cell of narrow.querySelectorAll<HTMLElement>(
461
+ "tbody td",
462
+ )) {
463
+ await expect(
464
+ cell.getBoundingClientRect().width,
465
+ ).toBeGreaterThan(800)
466
+ }
467
+ const priority = narrow.querySelector(
468
+ "tbody tr td:nth-child(3) > span:last-child",
469
+ ) as HTMLElement
470
+ await expect(
471
+ priority.getBoundingClientRect().height,
472
+ ).toBeLessThan(
473
+ Number.parseFloat(
474
+ getComputedStyle(priority).lineHeight,
475
+ ) * 1.1,
476
+ )
477
+ for (const table of [narrow, wide]) {
478
+ await expect(
479
+ table.querySelectorAll("tbody tr:first-child td"),
480
+ ).toHaveLength(8)
481
+ await expect(table.scrollWidth).toBeLessThanOrEqual(
482
+ (table.parentElement as HTMLElement).clientWidth + 1,
483
+ )
484
+ }
485
+ await expectNoAxeViolations(canvasElement)
486
+ })
487
+ test("the explicit list layout stays stacked in a wide container", async () => {
488
+ await setViewport(DESKTOP)
489
+ const { canvas } = await mountStory(StackedList)
490
+ const table = expectAgentDrivable(canvas, {
491
+ role: "table",
492
+ name: "Tasks as a list",
493
+ })
494
+ await expect(
495
+ getComputedStyle(
496
+ table.querySelector("tbody td") as HTMLElement,
497
+ ).display,
498
+ ).toBe("flex")
499
+ await expect(
500
+ table.querySelector("tbody td > span[aria-hidden]"),
501
+ ).toBeVisible()
502
+ for (const cell of table.querySelectorAll<HTMLElement>(
503
+ "tbody td",
504
+ )) {
505
+ await expect(
506
+ cell.getBoundingClientRect().width,
507
+ ).toBeGreaterThan(900)
508
+ }
509
+ })
@@ -58,7 +58,13 @@ export type DataTableSelection<Row> = {
58
58
  selectedRowKeys: readonly string[]
59
59
  }
60
60
 
61
+ export type DataTableReflowAt = "md" | "lg" | "xl"
62
+
61
63
  export type DataTableProps<Row> = {
64
+ /** Auto uses the container width; stacked keeps the labelled list at every width. */
65
+ layout?: "auto" | "stacked"
66
+ /** Dense tables can reflow earlier: md=32rem, lg=48rem, xl=64rem. */
67
+ reflowAt?: DataTableReflowAt
62
68
  className?: string
63
69
  columns: readonly DataTableColumn<Row>[]
64
70
  /**
@@ -100,6 +106,99 @@ export type DataTableProps<Row> = {
100
106
  * empty-state cell.
101
107
  */
102
108
  const CELL_PADDING_CLASS = "px-3 py-2"
109
+ /** Complete literals keep Tailwind generation and consumer builds identical. */
110
+ const WIDE_LAYOUT_CLASS = {
111
+ "cq-md:align-middle": {
112
+ md: "cq-md:align-middle",
113
+ lg: "cq-lg:align-middle",
114
+ xl: "cq-xl:align-middle",
115
+ },
116
+ "cq-md:bg-transparent": {
117
+ md: "cq-md:bg-transparent",
118
+ lg: "cq-lg:bg-transparent",
119
+ xl: "cq-xl:bg-transparent",
120
+ },
121
+ "cq-md:border-0": {
122
+ md: "cq-md:border-0",
123
+ lg: "cq-lg:border-0",
124
+ xl: "cq-xl:border-0",
125
+ },
126
+ "cq-md:border-b": {
127
+ md: "cq-md:border-b",
128
+ lg: "cq-lg:border-b",
129
+ xl: "cq-xl:border-b",
130
+ },
131
+ "cq-md:border-b-0": {
132
+ md: "cq-md:border-b-0",
133
+ lg: "cq-lg:border-b-0",
134
+ xl: "cq-xl:border-b-0",
135
+ },
136
+ "cq-md:border-border-subtle": {
137
+ md: "cq-md:border-border-subtle",
138
+ lg: "cq-lg:border-border-subtle",
139
+ xl: "cq-xl:border-border-subtle",
140
+ },
141
+ "cq-md:gap-0": {
142
+ md: "cq-md:gap-0",
143
+ lg: "cq-lg:gap-0",
144
+ xl: "cq-xl:gap-0",
145
+ },
146
+ "cq-md:hidden": {
147
+ md: "cq-md:hidden",
148
+ lg: "cq-lg:hidden",
149
+ xl: "cq-xl:hidden",
150
+ },
151
+ "cq-md:p-0": {
152
+ md: "cq-md:p-0",
153
+ lg: "cq-lg:p-0",
154
+ xl: "cq-xl:p-0",
155
+ },
156
+ "cq-md:pt-0": {
157
+ md: "cq-md:pt-0",
158
+ lg: "cq-lg:pt-0",
159
+ xl: "cq-xl:pt-0",
160
+ },
161
+ "cq-md:py-2": {
162
+ md: "cq-md:py-2",
163
+ lg: "cq-lg:py-2",
164
+ xl: "cq-xl:py-2",
165
+ },
166
+ "cq-md:rounded-none": {
167
+ md: "cq-md:rounded-none",
168
+ lg: "cq-lg:rounded-none",
169
+ xl: "cq-xl:rounded-none",
170
+ },
171
+ "cq-md:table": {
172
+ md: "cq-md:table",
173
+ lg: "cq-lg:table",
174
+ xl: "cq-xl:table",
175
+ },
176
+ "cq-md:table-cell": {
177
+ md: "cq-md:table-cell",
178
+ lg: "cq-lg:table-cell",
179
+ xl: "cq-xl:table-cell",
180
+ },
181
+ "cq-md:table-header-group": {
182
+ md: "cq-md:table-header-group",
183
+ lg: "cq-lg:table-header-group",
184
+ xl: "cq-xl:table-header-group",
185
+ },
186
+ "cq-md:table-row": {
187
+ md: "cq-md:table-row",
188
+ lg: "cq-lg:table-row",
189
+ xl: "cq-xl:table-row",
190
+ },
191
+ "cq-md:table-row-group": {
192
+ md: "cq-md:table-row-group",
193
+ lg: "cq-lg:table-row-group",
194
+ xl: "cq-xl:table-row-group",
195
+ },
196
+ "cq-md:wrap-normal": {
197
+ md: "cq-md:wrap-normal",
198
+ lg: "cq-lg:wrap-normal",
199
+ xl: "cq-xl:wrap-normal",
200
+ },
201
+ } as const
103
202
 
104
203
  /**
105
204
  * `role` on every part, and it is not belt-and-braces.
@@ -206,11 +305,19 @@ export const DataTable = <Row,>({
206
305
  getRowKey,
207
306
  isLabelVisible = false,
208
307
  label,
308
+ layout = "auto",
309
+ reflowAt = "md",
209
310
  onSortChange,
210
311
  rows,
211
312
  selection,
212
313
  sort,
213
314
  }: DataTableProps<Row>): ReactNode => {
315
+ const wideClass = (
316
+ key: keyof typeof WIDE_LAYOUT_CLASS,
317
+ ) =>
318
+ layout === "stacked"
319
+ ? undefined
320
+ : WIDE_LAYOUT_CLASS[key][reflowAt]
214
321
  const tableRef = useRef<HTMLTableElement>(null)
215
322
 
216
323
  const selectedRowKeys = selection?.selectedRowKeys
@@ -278,9 +385,19 @@ export const DataTable = <Row,>({
278
385
  // below it. `className` stays on this element — the outermost
279
386
  // box the component renders — which is what lets a caller give
280
387
  // the table a width, a margin, or a grid placement.
281
- <div className={toClassName("@container", className)}>
388
+ <div
389
+ className={toClassName(
390
+ "@container charcuterie-data-table",
391
+ className,
392
+ )}
393
+ data-layout={layout}
394
+ data-reflow-at={reflowAt}
395
+ >
282
396
  <table
283
- className="block w-full border-collapse text-start text-content-primary text-md cq-md:table"
397
+ className={toClassName(
398
+ "block w-full border-collapse text-start text-content-primary text-md",
399
+ wideClass("cq-md:table"),
400
+ )}
284
401
  ref={tableRef}
285
402
  role="table"
286
403
  >
@@ -296,20 +413,31 @@ export const DataTable = <Row,>({
296
413
  </caption>
297
414
 
298
415
  <thead
299
- className="block cq-md:table-header-group"
416
+ className={toClassName(
417
+ "block",
418
+ wideClass("cq-md:table-header-group"),
419
+ )}
300
420
  role={ROW_GROUP_ROLE}
301
421
  >
302
422
  {/* Narrow: a wrapping strip of sort controls, so sorting
303
423
  survives the layout that has no header row. Wide: the
304
424
  header row. */}
305
425
  <tr
306
- className="flex flex-wrap items-center gap-x-1 border-border-subtle border-b cq-md:table-row cq-md:border-b-0"
426
+ className={toClassName(
427
+ "flex flex-wrap items-center gap-x-1 border-border-subtle border-b",
428
+ wideClass("cq-md:table-row"),
429
+ wideClass("cq-md:border-b-0"),
430
+ )}
307
431
  role="row"
308
432
  >
309
433
  {selection ? (
310
434
  <th
311
435
  className={toClassName(
312
- "border-border-subtle p-2 text-start cq-md:table-cell cq-md:border-b",
436
+ toClassName(
437
+ "border-border-subtle p-2 text-start",
438
+ wideClass("cq-md:table-cell"),
439
+ wideClass("cq-md:border-b"),
440
+ ),
313
441
  )}
314
442
  role="columnheader"
315
443
  scope="col"
@@ -338,7 +466,10 @@ export const DataTable = <Row,>({
338
466
  // one underline per chip reads as five separate
339
467
  // rules rather than one header edge.
340
468
  className={toClassName(
341
- "border-b-0 cq-md:border-b",
469
+ toClassName(
470
+ "border-b-0",
471
+ wideClass("cq-md:border-b"),
472
+ ),
342
473
  column.className,
343
474
  )}
344
475
  direction={
@@ -359,7 +490,11 @@ export const DataTable = <Row,>({
359
490
  ) : (
360
491
  <th
361
492
  className={toClassName(
362
- "border-border-subtle text-start font-medium text-content-secondary text-sm uppercase tracking-wide cq-md:table-cell cq-md:border-b",
493
+ toClassName(
494
+ "border-border-subtle text-start wrap-normal font-medium text-content-secondary text-sm uppercase tracking-wide",
495
+ wideClass("cq-md:table-cell"),
496
+ wideClass("cq-md:border-b"),
497
+ ),
363
498
  CELL_PADDING_CLASS,
364
499
  column.className,
365
500
  )}
@@ -375,16 +510,27 @@ export const DataTable = <Row,>({
375
510
  </thead>
376
511
 
377
512
  <tbody
378
- className="flex flex-col gap-2 pt-2 cq-md:table-row-group cq-md:gap-0 cq-md:pt-0"
513
+ className={toClassName(
514
+ "flex flex-col gap-2 pt-2",
515
+ wideClass("cq-md:table-row-group"),
516
+ wideClass("cq-md:gap-0"),
517
+ wideClass("cq-md:pt-0"),
518
+ )}
379
519
  role={ROW_GROUP_ROLE}
380
520
  >
381
521
  {rows.length === 0 ? (
382
522
  <tr
383
- className="block cq-md:table-row"
523
+ className={toClassName(
524
+ "block",
525
+ wideClass("cq-md:table-row"),
526
+ )}
384
527
  role="row"
385
528
  >
386
529
  <td
387
- className="block p-0 cq-md:table-cell"
530
+ className={toClassName(
531
+ "block p-0",
532
+ wideClass("cq-md:table-cell"),
533
+ )}
388
534
  colSpan={columnCount}
389
535
  role="cell"
390
536
  >
@@ -405,10 +551,23 @@ export const DataTable = <Row,>({
405
551
  // than in a stylesheet so a reader can see the
406
552
  // whole of a row's shape in one place.
407
553
  "flex flex-col gap-1 rounded-lg border border-border-subtle p-3 transition-colors duration-(--duration-fast) ease-standard",
408
- "cq-md:table-row cq-md:gap-0 cq-md:rounded-none cq-md:border-0 cq-md:border-border-subtle cq-md:border-b cq-md:p-0",
554
+ toClassName(
555
+ wideClass("cq-md:table-row"),
556
+ wideClass("cq-md:gap-0"),
557
+ wideClass("cq-md:rounded-none"),
558
+ wideClass("cq-md:border-0"),
559
+ wideClass(
560
+ "cq-md:border-border-subtle",
561
+ ),
562
+ wideClass("cq-md:border-b"),
563
+ wideClass("cq-md:p-0"),
564
+ ),
409
565
  isSelected
410
566
  ? "bg-intent-accent-surface"
411
- : "bg-surface-raised hover:bg-intent-neutral-surface cq-md:bg-transparent",
567
+ : toClassName(
568
+ "bg-surface-raised hover:bg-intent-neutral-surface",
569
+ wideClass("cq-md:bg-transparent"),
570
+ ),
412
571
  )}
413
572
  // Not `aria-selected`: that property belongs to
414
573
  // `grid`/`treegrid` rows, and this is a `table`.
@@ -421,7 +580,11 @@ export const DataTable = <Row,>({
421
580
  >
422
581
  {selection ? (
423
582
  <td
424
- className="block p-2 cq-md:table-cell cq-md:align-middle"
583
+ className={toClassName(
584
+ "block p-2",
585
+ wideClass("cq-md:table-cell"),
586
+ wideClass("cq-md:align-middle"),
587
+ )}
425
588
  role="cell"
426
589
  >
427
590
  <Checkbox
@@ -451,9 +614,15 @@ export const DataTable = <Row,>({
451
614
  {columns.map((column) => (
452
615
  <td
453
616
  className={toClassName(
454
- "flex items-baseline gap-3 py-0.5 cq-md:table-cell cq-md:align-middle",
617
+ toClassName(
618
+ "flex items-baseline gap-3 py-0.5",
619
+ wideClass("cq-md:table-cell"),
620
+ wideClass("cq-md:align-middle"),
621
+ ),
455
622
  CELL_PADDING_CLASS,
456
- "cq-md:py-2",
623
+ toClassName(
624
+ wideClass("cq-md:py-2"),
625
+ ),
457
626
  column.className,
458
627
  )}
459
628
  key={column.key}
@@ -467,7 +636,10 @@ export const DataTable = <Row,>({
467
636
  out twice per cell. */}
468
637
  <span
469
638
  aria-hidden="true"
470
- className="w-20 shrink-0 truncate text-content-secondary text-xs uppercase tracking-wide cq-sm:w-24 cq-md:hidden"
639
+ className={toClassName(
640
+ "w-20 shrink-0 wrap-anywhere cq-xs:w-28 text-content-secondary text-xs uppercase tracking-wide",
641
+ wideClass("cq-md:hidden"),
642
+ )}
471
643
  >
472
644
  {column.header}
473
645
  </span>
@@ -481,7 +653,12 @@ export const DataTable = <Row,>({
481
653
  break-anywhere text asks for almost nothing
482
654
  and gets it — a title column one word wide
483
655
  with "extract or fan filter" underneath. */}
484
- <span className="min-w-0 flex-1 wrap-anywhere cq-md:wrap-normal">
656
+ <span
657
+ className={toClassName(
658
+ "min-w-0 flex-1 wrap-anywhere",
659
+ wideClass("cq-md:wrap-normal"),
660
+ )}
661
+ >
485
662
  {column.renderCell(row)}
486
663
  </span>
487
664
  </td>
@@ -0,0 +1,26 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as MetricCardStories from "./MetricCard.stories";
3
+
4
+ <Meta of={MetricCardStories} />
5
+
6
+ # MetricCard
7
+
8
+ A prominent measure with a stable category colour on its optional app-supplied icon and leading edge. The heading remains text, so colour is additional information. Apps format the value and supply supporting content. A zero and an unavailable reading remain different values.
9
+
10
+ <Canvas of={MetricCardStories.Playground} />
11
+
12
+ ## Props
13
+
14
+ <Controls />
15
+
16
+ ## Categories and available space
17
+
18
+ <Canvas of={MetricCardStories.AllVariants} />
19
+ <Canvas of={MetricCardStories.AllStates} />
20
+ <Canvas of={MetricCardStories.Responsive} />
21
+
22
+ ## Actions
23
+
24
+ Place actions in the inherited Card footer. Decorative icons are hidden from assistive technology; use the visible heading to identify the metric.
25
+
26
+ <Canvas of={MetricCardStories.Interactive} />
@@ -0,0 +1,104 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import { CATEGORICAL_INDEXES } from "@charcuterie/tokens"
3
+ import type { Meta, StoryObj } from "@storybook/react"
4
+ import { categoricalArgType } from "../argTypes.storyHelpers.ts"
5
+ import { Button } from "../Button/Button.tsx"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ } from "../board.storyHelpers.tsx"
11
+ import { SettingsIcon } from "../icons.storyHelpers.tsx"
12
+ import { MetricCard } from "./MetricCard.tsx"
13
+
14
+ const meta = {
15
+ title: "Components/Data/MetricCard",
16
+ component: MetricCard,
17
+ parameters: { layout: "padded" },
18
+ argTypes: { categorical: categoricalArgType },
19
+ args: {
20
+ heading: "Energy",
21
+ value: "0.35 kWh",
22
+ categorical: 3,
23
+ icon: <SettingsIcon />,
24
+ children: <p>Measured during the selected period</p>,
25
+ },
26
+ } satisfies Meta<typeof MetricCard>
27
+ export default meta
28
+ type Story = StoryObj<typeof meta>
29
+ export const Playground: Story = {
30
+ parameters: playgroundParameters,
31
+ }
32
+ export const AllVariants: Story = {
33
+ render: () => (
34
+ <StoryGrid columns={2}>
35
+ {CATEGORICAL_INDEXES.map((categorical) => (
36
+ <StoryCell
37
+ align="stretch"
38
+ label={`Category ${categorical}`}
39
+ key={categorical}
40
+ >
41
+ <MetricCard
42
+ heading={`Category ${categorical}`}
43
+ value="123.45"
44
+ categorical={categorical}
45
+ icon={<SettingsIcon />}
46
+ />
47
+ </StoryCell>
48
+ ))}
49
+ </StoryGrid>
50
+ ),
51
+ }
52
+ export const AllStates: Story = {
53
+ render: () => (
54
+ <StoryGrid columns={2}>
55
+ <StoryCell align="stretch" label="Zero is a value">
56
+ <MetricCard
57
+ heading="Recorded total"
58
+ value="0"
59
+ categorical={6}
60
+ />
61
+ </StoryCell>
62
+ <StoryCell
63
+ align="stretch"
64
+ label="Unavailable is app-owned"
65
+ >
66
+ <MetricCard
67
+ heading="Energy"
68
+ value="Unavailable"
69
+ categorical={10}
70
+ >
71
+ <p>No meter is connected.</p>
72
+ </MetricCard>
73
+ </StoryCell>
74
+ <StoryCell align="stretch" label="Neutral, no icon">
75
+ <MetricCard heading="Print time" value="14.5 h" />
76
+ </StoryCell>
77
+ </StoryGrid>
78
+ ),
79
+ }
80
+ export const Responsive: Story = {
81
+ render: () => (
82
+ <ContainerBoard>
83
+ {(width) => (
84
+ <MetricCard
85
+ heading={`Recorded cost at ${width}`}
86
+ value="$1,234.56"
87
+ categorical={9}
88
+ icon={<SettingsIcon />}
89
+ >
90
+ <p>
91
+ Includes the recorded filament and energy costs.
92
+ </p>
93
+ </MetricCard>
94
+ )}
95
+ </ContainerBoard>
96
+ ),
97
+ }
98
+ export const Interactive: Story = {
99
+ args: {
100
+ footer: (
101
+ <Button appearance="outline">Inspect records</Button>
102
+ ),
103
+ },
104
+ }
@@ -0,0 +1,64 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect } from "storybook/test"
3
+ import { test } from "vitest"
4
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./MetricCard.stories.tsx"
8
+
9
+ const { Playground, AllStates, Responsive, Interactive } =
10
+ composeStories(stories)
11
+
12
+ test("a metric is named by its visible heading and decorates its icon with its category colour", async () => {
13
+ const { canvas, canvasElement } =
14
+ await mountStory(Playground)
15
+ const region = expectAgentDrivable(canvas, {
16
+ role: "region",
17
+ name: "Energy",
18
+ })
19
+ await expect(region).toHaveTextContent("0.35 kWh")
20
+ const icon = region.querySelector(
21
+ 'span[aria-hidden="true"]',
22
+ ) as HTMLElement
23
+ await expect(icon).toBeVisible()
24
+ await expect(getComputedStyle(icon).color).not.toBe(
25
+ getComputedStyle(region).color,
26
+ )
27
+ await expect(
28
+ getComputedStyle(region, "::before").boxShadow,
29
+ ).not.toBe("none")
30
+ await expectNoAxeViolations(canvasElement)
31
+ })
32
+ test("zero and unavailable readings remain distinct", async () => {
33
+ const { canvas } = await mountStory(AllStates)
34
+ await expect(
35
+ expectAgentDrivable(canvas, {
36
+ role: "region",
37
+ name: "Recorded total",
38
+ }),
39
+ ).toHaveTextContent("0")
40
+ await expect(
41
+ expectAgentDrivable(canvas, {
42
+ role: "region",
43
+ name: "Energy",
44
+ }),
45
+ ).toHaveTextContent("Unavailable")
46
+ })
47
+ test("metric values and supporting text fit every container", async () => {
48
+ const { canvasElement } = await mountStory(Responsive)
49
+ for (const card of canvasElement.querySelectorAll<HTMLElement>(
50
+ "section",
51
+ )) {
52
+ await expect(card.scrollWidth).toBeLessThanOrEqual(
53
+ card.clientWidth + 1,
54
+ )
55
+ }
56
+ await expectNoAxeViolations(canvasElement)
57
+ })
58
+ test("a footer action is reachable by its accessible name", async () => {
59
+ const { canvas } = await mountStory(Interactive)
60
+ expectAgentDrivable(canvas, {
61
+ role: "button",
62
+ name: "Inspect records",
63
+ })
64
+ })