@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.
- package/dist/DataTable/DataTable.d.ts +6 -1
- package/dist/DataTable/DataTable.d.ts.map +1 -1
- package/dist/DataTable/DataTable.js +104 -8
- package/dist/DataTable/DataTable.js.map +1 -1
- package/dist/MetricCard/MetricCard.d.ts +14 -0
- package/dist/MetricCard/MetricCard.d.ts.map +1 -0
- package/dist/MetricCard/MetricCard.js +10 -0
- package/dist/MetricCard/MetricCard.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +46 -0
- package/package.json +1 -1
- package/src/DataTable/DataTable.mdx +11 -3
- package/src/DataTable/DataTable.stories.tsx +80 -0
- package/src/DataTable/DataTable.test.tsx +125 -2
- package/src/DataTable/DataTable.tsx +194 -17
- package/src/MetricCard/MetricCard.mdx +26 -0
- package/src/MetricCard/MetricCard.stories.tsx +104 -0
- package/src/MetricCard/MetricCard.test.tsx +64 -0
- package/src/MetricCard/MetricCard.tsx +51 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +8 -4
- package/src/sourceRules.test.ts +2 -1
- package/src/styles.css +46 -0
|
@@ -11,8 +11,15 @@ import {
|
|
|
11
11
|
} from "../viewport.testHelpers.ts"
|
|
12
12
|
import * as stories from "./DataTable.stories.tsx"
|
|
13
13
|
|
|
14
|
-
const {
|
|
15
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
523
|
+
className={toClassName(
|
|
524
|
+
"block",
|
|
525
|
+
wideClass("cq-md:table-row"),
|
|
526
|
+
)}
|
|
384
527
|
role="row"
|
|
385
528
|
>
|
|
386
529
|
<td
|
|
387
|
-
className=
|
|
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
|
-
|
|
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
|
-
:
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
+
})
|