@elabs-ai/components-data 4.1.0 → 5.0.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/README.md +1 -1
- package/dist/chunk-3OVN7MMO.js +2 -0
- package/dist/chunk-3OVN7MMO.js.map +1 -0
- package/dist/chunk-3UFWYVDS.js +2 -0
- package/dist/chunk-3UFWYVDS.js.map +1 -0
- package/dist/chunk-47ZBQZYS.js +47 -0
- package/dist/chunk-47ZBQZYS.js.map +1 -0
- package/dist/chunk-5K2IFOV2.js +2 -0
- package/dist/chunk-5K2IFOV2.js.map +1 -0
- package/dist/chunk-677ORWI6.js +17 -0
- package/dist/chunk-677ORWI6.js.map +1 -0
- package/dist/chunk-7ZYUY3UL.js +2 -0
- package/dist/chunk-7ZYUY3UL.js.map +1 -0
- package/dist/chunk-BEYHV3ZX.js +37 -0
- package/dist/chunk-BEYHV3ZX.js.map +1 -0
- package/dist/chunk-DBPJ7LVM.js +57 -0
- package/dist/chunk-DBPJ7LVM.js.map +1 -0
- package/dist/chunk-E3V3SYY7.js +87 -0
- package/dist/chunk-E3V3SYY7.js.map +1 -0
- package/dist/chunk-GBO56756.js +81 -0
- package/dist/chunk-GBO56756.js.map +1 -0
- package/dist/chunk-HG2YMSRM.js +95 -0
- package/dist/chunk-HG2YMSRM.js.map +1 -0
- package/dist/chunk-K2GHSRCP.js +1823 -0
- package/dist/chunk-K2GHSRCP.js.map +1 -0
- package/dist/chunk-KCB33PD3.js +43 -0
- package/dist/chunk-KCB33PD3.js.map +1 -0
- package/dist/chunk-MV2YVXEZ.js +67 -0
- package/dist/chunk-MV2YVXEZ.js.map +1 -0
- package/dist/chunk-NKVJUQYW.js +82 -0
- package/dist/chunk-NKVJUQYW.js.map +1 -0
- package/dist/chunk-OASM53LL.js +29 -0
- package/dist/chunk-OASM53LL.js.map +1 -0
- package/dist/chunk-RZ5MYZOA.js +2 -0
- package/dist/chunk-RZ5MYZOA.js.map +1 -0
- package/dist/chunk-SK6347WB.js +100 -0
- package/dist/chunk-SK6347WB.js.map +1 -0
- package/dist/chunk-TBB33PHQ.js +58 -0
- package/dist/chunk-TBB33PHQ.js.map +1 -0
- package/dist/chunk-VCLA45IC.js +150 -0
- package/dist/chunk-VCLA45IC.js.map +1 -0
- package/dist/chunk-XN6O6Z2N.js +58 -0
- package/dist/chunk-XN6O6Z2N.js.map +1 -0
- package/dist/chunk-ZFEW36RG.js +77 -0
- package/dist/chunk-ZFEW36RG.js.map +1 -0
- package/dist/chunk-ZQXJ34VJ.js +34 -0
- package/dist/chunk-ZQXJ34VJ.js.map +1 -0
- package/dist/chunk-ZUCGV2XD.js +42 -0
- package/dist/chunk-ZUCGV2XD.js.map +1 -0
- package/dist/column-picker/column-picker.js +8 -0
- package/dist/column-picker/column-picker.js.map +1 -0
- package/dist/column-picker/index.js +9 -0
- package/dist/column-picker/index.js.map +1 -0
- package/dist/data-table/card-layout.js +11 -0
- package/dist/data-table/card-layout.js.map +1 -0
- package/dist/data-table/cell-scales.js +26 -0
- package/dist/data-table/cell-scales.js.map +1 -0
- package/dist/data-table/cells/bar-cell.js +14 -0
- package/dist/data-table/cells/bar-cell.js.map +1 -0
- package/dist/data-table/cells/category-legend.js +9 -0
- package/dist/data-table/cells/category-legend.js.map +1 -0
- package/dist/data-table/cells/columns-cell.js +13 -0
- package/dist/data-table/cells/columns-cell.js.map +1 -0
- package/dist/data-table/cells/heatmap-cell.js +13 -0
- package/dist/data-table/cells/heatmap-cell.js.map +1 -0
- package/dist/data-table/cells/markdown-cell.js +13 -0
- package/dist/data-table/cells/markdown-cell.js.map +1 -0
- package/dist/data-table/cells/sparkline-cell.js +13 -0
- package/dist/data-table/cells/sparkline-cell.js.map +1 -0
- package/dist/data-table/column-meta.js +12 -0
- package/dist/data-table/column-meta.js.map +1 -0
- package/dist/data-table/data-table.js +23 -0
- package/dist/data-table/data-table.js.map +1 -0
- package/dist/data-table/index.js +23 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/data-table/ranks-column.js +13 -0
- package/dist/data-table/ranks-column.js.map +1 -0
- package/dist/data-table/sticky-rows.js +10 -0
- package/dist/data-table/sticky-rows.js.map +1 -0
- package/dist/data-table/use-table-breakpoint.js +13 -0
- package/dist/data-table/use-table-breakpoint.js.map +1 -0
- package/dist/facet-filter/facet-filter.js +8 -0
- package/dist/facet-filter/facet-filter.js.map +1 -0
- package/dist/facet-filter/index.js +9 -0
- package/dist/facet-filter/index.js.map +1 -0
- package/dist/filter-bar/filter-bar.js +8 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/filter-chip.js +9 -0
- package/dist/filter-bar/filter-chip.js.map +1 -0
- package/dist/filter-bar/index.js +13 -0
- package/dist/filter-bar/index.js.map +1 -0
- package/dist/index.d.ts +554 -10
- package/dist/index.js +102 -1494
- package/dist/index.js.map +1 -1
- package/dist/search-input/index.js +9 -0
- package/dist/search-input/index.js.map +1 -0
- package/dist/search-input/search-input.js +9 -0
- package/dist/search-input/search-input.js.map +1 -0
- package/dist/to-csv.js +10 -0
- package/dist/to-csv.js.map +1 -0
- package/package.json +7 -7
- package/src/__contract__/filter-chip.contract.test.tsx +49 -0
- package/src/column-picker/column-picker.tsx +3 -2
- package/src/data-table/card-layout.test.tsx +26 -0
- package/src/data-table/card-layout.tsx +74 -0
- package/src/data-table/cell-scales.test.ts +118 -0
- package/src/data-table/cell-scales.ts +246 -0
- package/src/data-table/cells/bar-cell.test.tsx +158 -0
- package/src/data-table/cells/bar-cell.tsx +135 -0
- package/src/data-table/cells/category-legend.test.tsx +37 -0
- package/src/data-table/cells/category-legend.tsx +58 -0
- package/src/data-table/cells/columns-cell.test.tsx +27 -0
- package/src/data-table/cells/columns-cell.tsx +79 -0
- package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
- package/src/data-table/cells/heatmap-cell.tsx +116 -0
- package/src/data-table/cells/markdown-cell.test.tsx +36 -0
- package/src/data-table/cells/markdown-cell.tsx +91 -0
- package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
- package/src/data-table/cells/sparkline-cell.tsx +113 -0
- package/src/data-table/column-meta.test.ts +42 -0
- package/src/data-table/column-meta.ts +282 -0
- package/src/data-table/data-table.stories.tsx +748 -1
- package/src/data-table/data-table.test.tsx +547 -11
- package/src/data-table/data-table.tsx +1014 -175
- package/src/data-table/ranks-column.test.tsx +65 -0
- package/src/data-table/ranks-column.tsx +91 -0
- package/src/data-table/sticky-rows.test.ts +16 -0
- package/src/data-table/sticky-rows.ts +77 -0
- package/src/data-table/use-table-breakpoint.test.ts +14 -0
- package/src/data-table/use-table-breakpoint.ts +67 -0
- package/src/facet-filter/facet-filter.stories.tsx +4 -1
- package/src/facet-filter/facet-filter.test.tsx +3 -3
- package/src/filter-bar/filter-chip.stories.tsx +1 -0
- package/src/index.ts +64 -0
- package/src/search-input/search-input.test.tsx +35 -0
- package/src/search-input/search-input.tsx +44 -19
- package/src/templates-data-app.stories.tsx +7 -2
- package/src/to-csv.test.ts +13 -0
- package/src/to-csv.ts +7 -30
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
2
3
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
-
import { expect, fn, waitFor } from "storybook/test";
|
|
4
|
+
import { expect, fn, waitFor, within } from "storybook/test";
|
|
4
5
|
import { Badge, Button } from "@elabs-ai/components-ui";
|
|
5
6
|
import type {
|
|
6
7
|
ColumnDef,
|
|
@@ -98,6 +99,21 @@ export const Lines: Story = {
|
|
|
98
99
|
render: () => <DataTable columns={columns} data={rows} zebra={false} />,
|
|
99
100
|
};
|
|
100
101
|
|
|
102
|
+
// ─── Column dividers ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `columnDividers` adds a quiet `--rule` hairline between columns, in the header
|
|
106
|
+
* and the body. Off by default; pinned columns keep their own seam instead.
|
|
107
|
+
*/
|
|
108
|
+
export const ColumnDividers: Story = {
|
|
109
|
+
render: () => <DataTable columns={columns} data={rows} zebra={false} columnDividers />,
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
const header = canvasElement.querySelector("thead th");
|
|
112
|
+
await expect(header).not.toBeNull();
|
|
113
|
+
await expect(getComputedStyle(header as Element).borderInlineEndWidth).toBe("1px");
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
101
117
|
// ─── Sorted ───────────────────────────────────────────────────────────────────
|
|
102
118
|
|
|
103
119
|
export const Sorted: Story = {
|
|
@@ -886,6 +902,7 @@ export const WithColumnResizing: Story = {
|
|
|
886
902
|
* hide from a comfortable-only check.
|
|
887
903
|
*/
|
|
888
904
|
export const WithColumnResizingCompactDensity: Story = {
|
|
905
|
+
tags: ["!dev"],
|
|
889
906
|
parameters: {
|
|
890
907
|
docs: {
|
|
891
908
|
description: {
|
|
@@ -984,6 +1001,7 @@ export const WithColumnResizingRealDoubleClick: Story = {
|
|
|
984
1001
|
* growing) should still be caught on the unproblematic column too.
|
|
985
1002
|
*/
|
|
986
1003
|
export const WithColumnResizingSortToggleHitTest: Story = {
|
|
1004
|
+
name: "Column resizing — sort button vs. resize handle",
|
|
987
1005
|
parameters: {
|
|
988
1006
|
docs: {
|
|
989
1007
|
description: {
|
|
@@ -1142,6 +1160,7 @@ export const RowSelectionWithToolbar: Story = {
|
|
|
1142
1160
|
* `FocusRingClearanceDark`.
|
|
1143
1161
|
*/
|
|
1144
1162
|
export const RowSelectionWithToolbarDark: Story = {
|
|
1163
|
+
tags: ["!dev"],
|
|
1145
1164
|
name: "Row selection — with toolbar (dark)",
|
|
1146
1165
|
globals: { theme: "dark" },
|
|
1147
1166
|
render: RowSelectionWithToolbar.render,
|
|
@@ -1151,6 +1170,53 @@ export const RowSelectionWithToolbarDark: Story = {
|
|
|
1151
1170
|
},
|
|
1152
1171
|
};
|
|
1153
1172
|
|
|
1173
|
+
// ─── Table-header & selection theming seams ────────────────────────────────
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* A theme dials the header row's background/foreground/size/transform/tracking
|
|
1177
|
+
* and the row-selection colour through the `--table-header-*` and
|
|
1178
|
+
* `--selection*` contract tokens — independently of `--accent`, so a
|
|
1179
|
+
* "selected" row can read differently from a hovered/focused one. Every token
|
|
1180
|
+
* defaults to today's rendering (see `RowSelectionPartial`); this story sets
|
|
1181
|
+
* them on an ancestor `style`, the same seam a theme author dials once in
|
|
1182
|
+
* `themes.css`.
|
|
1183
|
+
*/
|
|
1184
|
+
export const ThemedHeaderAndSelection: Story = {
|
|
1185
|
+
render: () => (
|
|
1186
|
+
<div
|
|
1187
|
+
style={
|
|
1188
|
+
{
|
|
1189
|
+
"--table-header-background": "var(--muted)",
|
|
1190
|
+
"--table-header-transform": "uppercase",
|
|
1191
|
+
"--table-header-size": "0.75rem",
|
|
1192
|
+
"--table-header-tracking": "0.06em",
|
|
1193
|
+
// A light wash, not the solid `--info` mark — the row's own body
|
|
1194
|
+
// text (unmanaged by `--selection-foreground`, see below) has to
|
|
1195
|
+
// stay readable ON this fill, the same reason `--accent`'s default
|
|
1196
|
+
// is a near-white tint rather than a saturated color.
|
|
1197
|
+
"--selection": "color-mix(in oklab, var(--info) 20%, var(--card))",
|
|
1198
|
+
"--selection-foreground": "var(--info-foreground)",
|
|
1199
|
+
} as CSSProperties
|
|
1200
|
+
}
|
|
1201
|
+
>
|
|
1202
|
+
<DataTable
|
|
1203
|
+
columns={selectableColumns}
|
|
1204
|
+
data={rows}
|
|
1205
|
+
rowSelection={{ "0": true, "2": true }}
|
|
1206
|
+
onRowSelectionChange={fn()}
|
|
1207
|
+
/>
|
|
1208
|
+
</div>
|
|
1209
|
+
),
|
|
1210
|
+
play: async ({ canvasElement }) => {
|
|
1211
|
+
const bodyRows = canvasElement.querySelectorAll("tbody tr");
|
|
1212
|
+
await expect(bodyRows[0]).toHaveAttribute("data-state", "selected");
|
|
1213
|
+
await expect(bodyRows[1]).not.toHaveAttribute("data-state", "selected");
|
|
1214
|
+
await expect(bodyRows[2]).toHaveAttribute("data-state", "selected");
|
|
1215
|
+
const headerCell = canvasElement.querySelector("thead th");
|
|
1216
|
+
await expect(headerCell).not.toBeNull();
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1154
1220
|
// ─── RowReorder (#13) ───────────────────────────────────────────────────────
|
|
1155
1221
|
|
|
1156
1222
|
/**
|
|
@@ -1281,3 +1347,684 @@ export const RowReorderWholeRow: Story = {
|
|
|
1281
1347
|
});
|
|
1282
1348
|
},
|
|
1283
1349
|
};
|
|
1350
|
+
// ─── Presentation layer: in-cell visuals, format, colour, layout ─────────────
|
|
1351
|
+
// (Table presentation layer; the stories below never change a
|
|
1352
|
+
// default — every one opts into its prop explicitly.)
|
|
1353
|
+
|
|
1354
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
1355
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")}:00`);
|
|
1356
|
+
|
|
1357
|
+
interface HourlyRides {
|
|
1358
|
+
month: string;
|
|
1359
|
+
[hour: string]: number | string;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Deterministic rides per hour: a commute double-peak, busier in summer. */
|
|
1363
|
+
const hourlyRides: HourlyRides[] = MONTHS.map((month, m) => {
|
|
1364
|
+
const season = 1 + 0.5 * Math.sin(((m - 3) / 12) * 2 * Math.PI);
|
|
1365
|
+
const row: HourlyRides = { month };
|
|
1366
|
+
HOURS.forEach((hour, h) => {
|
|
1367
|
+
const commute = Math.exp(-((h - 8) ** 2) / 4) + 0.9 * Math.exp(-((h - 17) ** 2) / 5);
|
|
1368
|
+
const day = h >= 6 && h <= 22 ? 0.35 : 0.05;
|
|
1369
|
+
row[hour] = Math.round(season * (day + commute) * 400);
|
|
1370
|
+
});
|
|
1371
|
+
return row;
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const heatmapScale = { type: "stepped", steps: 5 } as const;
|
|
1375
|
+
const heatmapColumns: ColumnDef<HourlyRides>[] = [
|
|
1376
|
+
{ accessorKey: "month", header: "Month", meta: { width: 12 } },
|
|
1377
|
+
...HOURS.map(
|
|
1378
|
+
(hour, h): ColumnDef<HourlyRides> => ({
|
|
1379
|
+
accessorKey: hour,
|
|
1380
|
+
header: hour,
|
|
1381
|
+
enableSorting: true,
|
|
1382
|
+
meta: {
|
|
1383
|
+
// Percent widths let the 24 hour columns shrink proportionally with the
|
|
1384
|
+
// table instead of flooring at their content width.
|
|
1385
|
+
width: 88 / 24,
|
|
1386
|
+
numeric: true,
|
|
1387
|
+
visual: {
|
|
1388
|
+
kind: "heatmap",
|
|
1389
|
+
scale: heatmapScale,
|
|
1390
|
+
hideValue: true,
|
|
1391
|
+
// The 24 columns share ONE scale; the key is printed once.
|
|
1392
|
+
legend: h === 0 ? "Rides per hour" : false,
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1395
|
+
}),
|
|
1396
|
+
),
|
|
1397
|
+
];
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* A 12 × 24 heatmap: one scale shared by every hour column, values hidden
|
|
1401
|
+
* visually (still read by screen readers, copy and sort), the header row hidden,
|
|
1402
|
+
* and one colour key. Cells have no minimum width, so they shrink with the table.
|
|
1403
|
+
* With the header hidden there is no band to click: tab to a column's sort button
|
|
1404
|
+
* (it shows itself on focus) and press Enter.
|
|
1405
|
+
*/
|
|
1406
|
+
export const HeatmapCells: Story = {
|
|
1407
|
+
render: () => (
|
|
1408
|
+
<div className="w-full max-w-[900px]">
|
|
1409
|
+
<DataTable
|
|
1410
|
+
columns={heatmapColumns}
|
|
1411
|
+
data={hourlyRides}
|
|
1412
|
+
getRowId={(row) => row.month}
|
|
1413
|
+
hideHeader
|
|
1414
|
+
density="compact"
|
|
1415
|
+
caption="Bike rides per hour of the day, by month"
|
|
1416
|
+
/>
|
|
1417
|
+
</div>
|
|
1418
|
+
),
|
|
1419
|
+
play: async ({ canvasElement, canvas, userEvent }) => {
|
|
1420
|
+
const cells = [...canvasElement.querySelectorAll<HTMLElement>('[data-slot="heatmap-cell"]')];
|
|
1421
|
+
await expect(cells).toHaveLength(12 * 24);
|
|
1422
|
+
for (const cell of cells) {
|
|
1423
|
+
await expect(cell).toHaveClass("sr-only");
|
|
1424
|
+
await expect(cell.closest("td")?.style.backgroundColor).toMatch(/^var\(--chart-seq-\d\)$/);
|
|
1425
|
+
}
|
|
1426
|
+
await expect(canvas.getByRole("group", { name: "Rides per hour" })).toBeInTheDocument();
|
|
1427
|
+
// The header is hidden, not gone: its sort buttons still take focus and sort.
|
|
1428
|
+
const sort = canvas.getByRole("button", { name: /^Sort by 08:00/ });
|
|
1429
|
+
sort.focus();
|
|
1430
|
+
await expect(sort).toHaveFocus();
|
|
1431
|
+
// The reveal can land a frame after focus, so the key press is retried.
|
|
1432
|
+
await waitFor(async () => {
|
|
1433
|
+
if (sort.getAttribute("aria-label")?.includes("not sorted")) {
|
|
1434
|
+
sort.focus();
|
|
1435
|
+
await userEvent.keyboard("{Enter}");
|
|
1436
|
+
}
|
|
1437
|
+
await expect(sort).toHaveAccessibleName("Sort by 08:00, descending");
|
|
1438
|
+
});
|
|
1439
|
+
const column = [...canvasElement.querySelectorAll("tbody tr")].map((tr) =>
|
|
1440
|
+
Number(tr.querySelectorAll("td")[9]?.textContent),
|
|
1441
|
+
);
|
|
1442
|
+
await expect(column).toEqual([...column].sort((a, b) => b - a));
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
interface CityStat {
|
|
1447
|
+
city: string;
|
|
1448
|
+
region: "North" | "South" | "East";
|
|
1449
|
+
rides: number;
|
|
1450
|
+
change: number;
|
|
1451
|
+
share: number;
|
|
1452
|
+
q1: number;
|
|
1453
|
+
q2: number;
|
|
1454
|
+
q3: number;
|
|
1455
|
+
q4: number;
|
|
1456
|
+
note: string;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const cityStats: CityStat[] = [
|
|
1460
|
+
{
|
|
1461
|
+
city: "Oslo",
|
|
1462
|
+
region: "North",
|
|
1463
|
+
rides: 1840,
|
|
1464
|
+
change: 12.5,
|
|
1465
|
+
share: 0.21,
|
|
1466
|
+
q1: 320,
|
|
1467
|
+
q2: 510,
|
|
1468
|
+
q3: 640,
|
|
1469
|
+
q4: 370,
|
|
1470
|
+
note: "**Record** summer",
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
city: "Lyon",
|
|
1474
|
+
region: "South",
|
|
1475
|
+
rides: 2410,
|
|
1476
|
+
change: -4.2,
|
|
1477
|
+
share: 0.27,
|
|
1478
|
+
q1: 480,
|
|
1479
|
+
q2: 640,
|
|
1480
|
+
q3: 700,
|
|
1481
|
+
q4: 590,
|
|
1482
|
+
note: "Station works in *Q1*",
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
city: "Graz",
|
|
1486
|
+
region: "East",
|
|
1487
|
+
rides: 920,
|
|
1488
|
+
change: 3.1,
|
|
1489
|
+
share: 0.1,
|
|
1490
|
+
q1: 150,
|
|
1491
|
+
q2: 260,
|
|
1492
|
+
q3: 310,
|
|
1493
|
+
q4: 200,
|
|
1494
|
+
note: "See [method notes](#method)",
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
city: "Porto",
|
|
1498
|
+
region: "South",
|
|
1499
|
+
rides: 1260,
|
|
1500
|
+
change: -9.8,
|
|
1501
|
+
share: 0.14,
|
|
1502
|
+
q1: 210,
|
|
1503
|
+
q2: 300,
|
|
1504
|
+
q3: 460,
|
|
1505
|
+
q4: 290,
|
|
1506
|
+
note: "`pedelec` share up",
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
city: "Turku",
|
|
1510
|
+
region: "North",
|
|
1511
|
+
rides: 610,
|
|
1512
|
+
change: 0.4,
|
|
1513
|
+
share: 0.07,
|
|
1514
|
+
q1: 90,
|
|
1515
|
+
q2: 170,
|
|
1516
|
+
q3: 700,
|
|
1517
|
+
q4: 110,
|
|
1518
|
+
note: "Festival week",
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
city: "Brno",
|
|
1522
|
+
region: "East",
|
|
1523
|
+
rides: 1780,
|
|
1524
|
+
change: 7.9,
|
|
1525
|
+
share: 0.2,
|
|
1526
|
+
q1: 300,
|
|
1527
|
+
q2: 480,
|
|
1528
|
+
q3: 620,
|
|
1529
|
+
q4: 380,
|
|
1530
|
+
note: "CO~2~ saved: 41 t",
|
|
1531
|
+
},
|
|
1532
|
+
];
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* In-cell bars. `range: "column"` sizes every bar against the column's own
|
|
1536
|
+
* maximum (the largest value fills the track); `track` paints the remainder;
|
|
1537
|
+
* a negative value grows left of the zero rule in the negative token.
|
|
1538
|
+
*/
|
|
1539
|
+
export const BarCells: Story = {
|
|
1540
|
+
render: () => (
|
|
1541
|
+
<div className="w-full max-w-[720px]">
|
|
1542
|
+
<DataTable<CityStat, unknown>
|
|
1543
|
+
columns={[
|
|
1544
|
+
{ accessorKey: "city", header: "City" },
|
|
1545
|
+
{
|
|
1546
|
+
accessorKey: "rides",
|
|
1547
|
+
header: "Rides",
|
|
1548
|
+
enableSorting: true,
|
|
1549
|
+
meta: {
|
|
1550
|
+
numeric: true,
|
|
1551
|
+
visual: { kind: "bar", range: "column", track: true },
|
|
1552
|
+
},
|
|
1553
|
+
},
|
|
1554
|
+
{
|
|
1555
|
+
accessorKey: "change",
|
|
1556
|
+
header: "Change",
|
|
1557
|
+
meta: {
|
|
1558
|
+
numeric: true,
|
|
1559
|
+
format: { sign: "always", suffix: " %" },
|
|
1560
|
+
visual: { kind: "bar" },
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
accessorKey: "share",
|
|
1565
|
+
header: "Share",
|
|
1566
|
+
meta: {
|
|
1567
|
+
format: { style: "percent", decimals: 0 },
|
|
1568
|
+
visual: {
|
|
1569
|
+
kind: "bar",
|
|
1570
|
+
style: "slim",
|
|
1571
|
+
range: [0, 1],
|
|
1572
|
+
// The table prints no region column, so the fill is the only
|
|
1573
|
+
// carrier of the category: it ships its key (b-6).
|
|
1574
|
+
colorBy: "region",
|
|
1575
|
+
legend: "Region",
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
},
|
|
1579
|
+
]}
|
|
1580
|
+
data={cityStats}
|
|
1581
|
+
getRowId={(row) => row.city}
|
|
1582
|
+
/>
|
|
1583
|
+
</div>
|
|
1584
|
+
),
|
|
1585
|
+
play: async ({ canvasElement }) => {
|
|
1586
|
+
const bars = (col: number) =>
|
|
1587
|
+
[
|
|
1588
|
+
...canvasElement.querySelectorAll<HTMLElement>(
|
|
1589
|
+
`tbody td:nth-child(${col}) [data-slot="bar-cell-bar"]`,
|
|
1590
|
+
),
|
|
1591
|
+
].map((bar) => bar.style);
|
|
1592
|
+
// Lyon (2,410) is the column max: its bar fills the track.
|
|
1593
|
+
const rides = bars(2).map((s) => Number.parseFloat(s.width));
|
|
1594
|
+
await expect(Math.max(...rides)).toBe(100);
|
|
1595
|
+
await expect(rides[0]).toBeCloseTo((1840 / 2410) * 100, 1);
|
|
1596
|
+
// Bars compare DOWN the column, in pixels: every track in a column is the
|
|
1597
|
+
// same length (the value box is a column-wide reservation), so a rendered
|
|
1598
|
+
// bar's share of the longest bar equals its share of the column max.
|
|
1599
|
+
const rects = (selector: string) =>
|
|
1600
|
+
[...canvasElement.querySelectorAll<HTMLElement>(`tbody td:nth-child(2) ${selector}`)].map(
|
|
1601
|
+
(el) => el.getBoundingClientRect(),
|
|
1602
|
+
);
|
|
1603
|
+
const tracks = rects('[data-slot="bar-cell-track"]').map((r) => Math.round(r.width));
|
|
1604
|
+
await expect(new Set(tracks).size).toBe(1);
|
|
1605
|
+
const drawn = rects('[data-slot="bar-cell-bar"]').map((r) => r.width);
|
|
1606
|
+
const longest = Math.max(...drawn);
|
|
1607
|
+
for (const [i, value] of [1840, 2410, 920, 1260, 610, 1780].entries()) {
|
|
1608
|
+
await expect(drawn[i]! / longest).toBeCloseTo(value / 2410, 2);
|
|
1609
|
+
}
|
|
1610
|
+
// The same reservation keeps the diverging column's zero rule on one x.
|
|
1611
|
+
const zeros = [
|
|
1612
|
+
...canvasElement.querySelectorAll<HTMLElement>(
|
|
1613
|
+
'tbody td:nth-child(3) [data-slot="bar-cell-zero"]',
|
|
1614
|
+
),
|
|
1615
|
+
].map((el) => Math.round(el.getBoundingClientRect().x));
|
|
1616
|
+
await expect(new Set(zeros).size).toBe(1);
|
|
1617
|
+
const [, lyon] = bars(3);
|
|
1618
|
+
await expect(lyon?.backgroundColor).toBe("var(--chart-div-neg-2)");
|
|
1619
|
+
await expect(
|
|
1620
|
+
canvasElement.querySelector('tbody td:nth-child(3) [data-slot="bar-cell-zero"]'),
|
|
1621
|
+
).not.toBeNull();
|
|
1622
|
+
await expect(
|
|
1623
|
+
canvasElement.querySelector("tbody tr:nth-child(2) td:nth-child(3)"),
|
|
1624
|
+
).toHaveTextContent("-4.2 %");
|
|
1625
|
+
// The slim column is a real mark, not just a number: measured, not classed,
|
|
1626
|
+
// so a track that collapses to 0px on the flex axis fails here.
|
|
1627
|
+
const slim = (selector: string) =>
|
|
1628
|
+
[...canvasElement.querySelectorAll<HTMLElement>(`tbody td:nth-child(4) ${selector}`)].map(
|
|
1629
|
+
(el) => el.getBoundingClientRect(),
|
|
1630
|
+
);
|
|
1631
|
+
const slimTracks = slim('[data-slot="bar-cell-track"]');
|
|
1632
|
+
await expect(slimTracks).toHaveLength(6);
|
|
1633
|
+
for (const rect of slimTracks) {
|
|
1634
|
+
await expect(rect.height).toBeGreaterThan(0);
|
|
1635
|
+
await expect(rect.width).toBeGreaterThan(0);
|
|
1636
|
+
}
|
|
1637
|
+
for (const rect of slim('[data-slot="bar-cell-bar"]')) {
|
|
1638
|
+
await expect(rect.height).toBeGreaterThan(0);
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Sparklines and mini columns from the quarter columns. With `range: "column"`
|
|
1645
|
+
* every row shares one y scale, so Lyon's and Turku's 700 peaks sit at the same
|
|
1646
|
+
* height; the values stay readable to screen readers.
|
|
1647
|
+
*/
|
|
1648
|
+
export const SparklineAndColumnCells: Story = {
|
|
1649
|
+
render: () => (
|
|
1650
|
+
<div className="w-full max-w-[720px]">
|
|
1651
|
+
<DataTable<CityStat, unknown>
|
|
1652
|
+
columns={[
|
|
1653
|
+
{ accessorKey: "city", header: "City" },
|
|
1654
|
+
{
|
|
1655
|
+
id: "trend",
|
|
1656
|
+
header: "Quarterly trend",
|
|
1657
|
+
meta: {
|
|
1658
|
+
visual: {
|
|
1659
|
+
kind: "sparkline",
|
|
1660
|
+
keys: ["q1", "q2", "q3", "q4"],
|
|
1661
|
+
range: "column",
|
|
1662
|
+
labels: "ends",
|
|
1663
|
+
},
|
|
1664
|
+
},
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
id: "quarters",
|
|
1668
|
+
header: "By quarter",
|
|
1669
|
+
meta: {
|
|
1670
|
+
visual: {
|
|
1671
|
+
kind: "columns",
|
|
1672
|
+
keys: ["q1", "q2", "q3", "q4"],
|
|
1673
|
+
range: "column",
|
|
1674
|
+
},
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
]}
|
|
1678
|
+
data={cityStats}
|
|
1679
|
+
getRowId={(row) => row.city}
|
|
1680
|
+
/>
|
|
1681
|
+
</div>
|
|
1682
|
+
),
|
|
1683
|
+
play: async ({ canvasElement, canvas }) => {
|
|
1684
|
+
const maxes = [
|
|
1685
|
+
...canvasElement.querySelectorAll(
|
|
1686
|
+
'[data-slot="sparkline-cell-svg"], [data-slot="columns-cell-svg"]',
|
|
1687
|
+
),
|
|
1688
|
+
].map((svg) => svg.getAttribute("data-y-max"));
|
|
1689
|
+
await expect(new Set(maxes)).toEqual(new Set(["700"]));
|
|
1690
|
+
// The printed end labels get a column-wide box, so every row's drawing is
|
|
1691
|
+
// the same width: the lines share the x scale as well as the y scale.
|
|
1692
|
+
const widths = [
|
|
1693
|
+
...canvasElement.querySelectorAll<SVGElement>('[data-slot="sparkline-cell-svg"]'),
|
|
1694
|
+
].map((svg) => Math.round(svg.getBoundingClientRect().width));
|
|
1695
|
+
await expect(new Set(widths).size).toBe(1);
|
|
1696
|
+
const lyon = canvas.getByRole("row", { name: /Lyon/ });
|
|
1697
|
+
await expect(lyon).toHaveTextContent(/480.*640.*700.*590/);
|
|
1698
|
+
},
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* `colorBy` washes a row (or a cell's text) by a category, from the shared
|
|
1703
|
+
* categorical palette. The category is printed too — colour is never the only cue.
|
|
1704
|
+
*/
|
|
1705
|
+
export const CategoryColouring: Story = {
|
|
1706
|
+
render: () => (
|
|
1707
|
+
<div className="w-full max-w-[640px]">
|
|
1708
|
+
<DataTable<CityStat, unknown>
|
|
1709
|
+
columns={[
|
|
1710
|
+
{
|
|
1711
|
+
accessorKey: "city",
|
|
1712
|
+
header: "City",
|
|
1713
|
+
meta: {
|
|
1714
|
+
colorBy: { key: "region", target: "background", scope: "row" },
|
|
1715
|
+
},
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
accessorKey: "region",
|
|
1719
|
+
header: "Region",
|
|
1720
|
+
meta: { colorBy: { key: "region", target: "text" } },
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
accessorKey: "rides",
|
|
1724
|
+
header: "Rides",
|
|
1725
|
+
meta: { numeric: true, format: { abbreviate: false, decimals: 0 } },
|
|
1726
|
+
},
|
|
1727
|
+
]}
|
|
1728
|
+
data={cityStats}
|
|
1729
|
+
getRowId={(row) => row.city}
|
|
1730
|
+
/>
|
|
1731
|
+
</div>
|
|
1732
|
+
),
|
|
1733
|
+
play: async ({ canvas }) => {
|
|
1734
|
+
const oslo = canvas.getByRole("row", { name: /Oslo/ });
|
|
1735
|
+
const lyon = canvas.getByRole("row", { name: /Lyon/ });
|
|
1736
|
+
await expect(oslo.style.backgroundColor).toMatch(/color-mix/);
|
|
1737
|
+
await expect(oslo.style.backgroundColor).not.toBe(lyon.style.backgroundColor);
|
|
1738
|
+
await expect(canvas.getByRole("row", { name: /Turku/ }).style.backgroundColor).toBe(
|
|
1739
|
+
oslo.style.backgroundColor,
|
|
1740
|
+
);
|
|
1741
|
+
},
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* `meta.markdown` renders a safe inline subset (bold, italics, code, links,
|
|
1746
|
+
* superscript and subscript) as elements — never as HTML.
|
|
1747
|
+
*/
|
|
1748
|
+
export const MarkdownCells: Story = {
|
|
1749
|
+
render: () => (
|
|
1750
|
+
<div className="w-full max-w-[640px]">
|
|
1751
|
+
<DataTable<CityStat, unknown>
|
|
1752
|
+
columns={[
|
|
1753
|
+
{ accessorKey: "city", header: "City" },
|
|
1754
|
+
{ accessorKey: "note", header: "Note", meta: { markdown: true } },
|
|
1755
|
+
]}
|
|
1756
|
+
data={cityStats}
|
|
1757
|
+
getRowId={(row) => row.city}
|
|
1758
|
+
/>
|
|
1759
|
+
</div>
|
|
1760
|
+
),
|
|
1761
|
+
play: async ({ canvas, canvasElement }) => {
|
|
1762
|
+
await expect(canvas.getByText("Record").tagName).toBe("STRONG");
|
|
1763
|
+
await expect(canvas.getByRole("link", { name: "method notes" })).toHaveAttribute(
|
|
1764
|
+
"href",
|
|
1765
|
+
"#method",
|
|
1766
|
+
);
|
|
1767
|
+
await expect(canvasElement.querySelector("code")).toHaveTextContent("pedelec");
|
|
1768
|
+
},
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
const responsiveColumns: ColumnDef<CityStat>[] = [
|
|
1772
|
+
{ accessorKey: "city", header: "City", enableSorting: true },
|
|
1773
|
+
{
|
|
1774
|
+
accessorKey: "region",
|
|
1775
|
+
header: "Region",
|
|
1776
|
+
meta: { showAt: { base: true, narrow: false } },
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
accessorKey: "rides",
|
|
1780
|
+
header: "Rides",
|
|
1781
|
+
enableSorting: true,
|
|
1782
|
+
meta: { numeric: true, visual: { kind: "bar", track: true } },
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
accessorKey: "change",
|
|
1786
|
+
header: "Change",
|
|
1787
|
+
enableSorting: true,
|
|
1788
|
+
meta: { numeric: true, format: { sign: "always", suffix: " %" } },
|
|
1789
|
+
},
|
|
1790
|
+
];
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* `layout="auto"`: a `<table>` at 450 px and wider, one `<dl>` card per row
|
|
1794
|
+
* below it. "Region" sets `showAt={{ base: true, narrow: false }}`, so it drops
|
|
1795
|
+
* out under 450 px. Resize the canvas (or use the viewport toolbar) to switch.
|
|
1796
|
+
*/
|
|
1797
|
+
export const CardLayoutAuto: Story = {
|
|
1798
|
+
render: () => (
|
|
1799
|
+
<div className="w-full max-w-[900px]">
|
|
1800
|
+
<DataTable
|
|
1801
|
+
columns={responsiveColumns}
|
|
1802
|
+
data={cityStats}
|
|
1803
|
+
getRowId={(row) => row.city}
|
|
1804
|
+
layout="auto"
|
|
1805
|
+
caption="Rides by city"
|
|
1806
|
+
/>
|
|
1807
|
+
</div>
|
|
1808
|
+
),
|
|
1809
|
+
play: async ({ canvasElement, canvas }) => {
|
|
1810
|
+
const root = canvasElement.querySelector("[data-layout]");
|
|
1811
|
+
if (root?.getAttribute("data-layout") === "cards") {
|
|
1812
|
+
await expect(canvasElement.querySelector("table")).toBeNull();
|
|
1813
|
+
await expect(canvasElement.querySelectorAll('[data-slot="data-table-card"] dl')).toHaveLength(
|
|
1814
|
+
cityStats.length,
|
|
1815
|
+
);
|
|
1816
|
+
await expect(canvas.queryByRole("term", { name: "Region" })).toBeNull();
|
|
1817
|
+
} else {
|
|
1818
|
+
await expect(canvas.getByRole("table")).toBeInTheDocument();
|
|
1819
|
+
await expect(canvas.getByRole("columnheader", { name: /Region/ })).toBeInTheDocument();
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* `layout="cards"` at every width, with selection. Sorting moves to a sort bar
|
|
1826
|
+
* above the cards and works from the keyboard.
|
|
1827
|
+
*/
|
|
1828
|
+
export const CardLayout: Story = {
|
|
1829
|
+
render: () => (
|
|
1830
|
+
<div className="w-full max-w-[420px]">
|
|
1831
|
+
<DataTable
|
|
1832
|
+
columns={[createSelectionColumn<CityStat>(), ...responsiveColumns]}
|
|
1833
|
+
data={cityStats}
|
|
1834
|
+
getRowId={(row) => row.city}
|
|
1835
|
+
layout="cards"
|
|
1836
|
+
caption="Rides by city"
|
|
1837
|
+
/>
|
|
1838
|
+
</div>
|
|
1839
|
+
),
|
|
1840
|
+
play: async ({ canvasElement, canvas, userEvent }) => {
|
|
1841
|
+
const firstCity = () =>
|
|
1842
|
+
canvasElement.querySelector('[data-slot="data-table-card"] dd')?.textContent ?? "";
|
|
1843
|
+
await expect(firstCity()).toBe("Oslo");
|
|
1844
|
+
canvas.getByRole("button", { name: "Sort by City, not sorted" }).focus();
|
|
1845
|
+
await userEvent.keyboard("{Enter}");
|
|
1846
|
+
await expect(firstCity()).toBe("Brno");
|
|
1847
|
+
const firstCard = canvasElement.querySelector<HTMLElement>('[data-slot="data-table-card"]')!;
|
|
1848
|
+
await userEvent.click(within(firstCard).getByRole("checkbox"));
|
|
1849
|
+
await expect(canvasElement.querySelector('[data-slot="data-table-card"]')).toHaveAttribute(
|
|
1850
|
+
"data-state",
|
|
1851
|
+
"selected",
|
|
1852
|
+
);
|
|
1853
|
+
},
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
const manyCities: CityStat[] = Array.from({ length: 2000 }, (_, i) => {
|
|
1857
|
+
const base = cityStats[i % cityStats.length]!;
|
|
1858
|
+
return {
|
|
1859
|
+
...base,
|
|
1860
|
+
city: `${base.city} ${Math.floor(i / cityStats.length) + 1}`,
|
|
1861
|
+
};
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
/** Cards stay windowed under `enableRowVirtualization` — only the visible cards mount. */
|
|
1865
|
+
export const CardLayoutVirtualized: Story = {
|
|
1866
|
+
render: () => (
|
|
1867
|
+
<div className="w-full max-w-[420px]">
|
|
1868
|
+
<DataTable
|
|
1869
|
+
columns={responsiveColumns}
|
|
1870
|
+
data={manyCities}
|
|
1871
|
+
getRowId={(row) => row.city}
|
|
1872
|
+
layout="cards"
|
|
1873
|
+
enableRowVirtualization
|
|
1874
|
+
estimateRowHeight={140}
|
|
1875
|
+
maxBodyHeight="28rem"
|
|
1876
|
+
/>
|
|
1877
|
+
</div>
|
|
1878
|
+
),
|
|
1879
|
+
play: async ({ canvasElement }) => {
|
|
1880
|
+
const cards = () => canvasElement.querySelectorAll('[data-slot="data-table-card"]').length;
|
|
1881
|
+
await waitFor(() => expect(cards()).toBeGreaterThan(0));
|
|
1882
|
+
await expect(cards()).toBeLessThan(60);
|
|
1883
|
+
},
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const withAverage: CityStat[] = [
|
|
1887
|
+
...cityStats,
|
|
1888
|
+
{
|
|
1889
|
+
city: "Average",
|
|
1890
|
+
region: "North",
|
|
1891
|
+
rides: 1470,
|
|
1892
|
+
change: 1.7,
|
|
1893
|
+
share: 0.17,
|
|
1894
|
+
q1: 258,
|
|
1895
|
+
q2: 393,
|
|
1896
|
+
q3: 572,
|
|
1897
|
+
q4: 323,
|
|
1898
|
+
note: "",
|
|
1899
|
+
},
|
|
1900
|
+
];
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* `stickyRows` keeps the "Average" row at the bottom of every page and after
|
|
1904
|
+
* every sort; `showRanks` numbers the ordinary rows 1…n in data order, and the
|
|
1905
|
+
* rank travels with its row when the table is re-sorted.
|
|
1906
|
+
*/
|
|
1907
|
+
export const StickyRowsAndRanks: Story = {
|
|
1908
|
+
render: () => (
|
|
1909
|
+
<div className="w-full max-w-[640px]">
|
|
1910
|
+
<DataTable<CityStat, unknown>
|
|
1911
|
+
columns={responsiveColumns.filter((c) => c.header !== "Region")}
|
|
1912
|
+
data={withAverage}
|
|
1913
|
+
getRowId={(row) => row.city}
|
|
1914
|
+
enablePagination
|
|
1915
|
+
pageSize={4}
|
|
1916
|
+
stickyRows={(row) => (row.city === "Average" ? "bottom" : undefined)}
|
|
1917
|
+
showRanks
|
|
1918
|
+
/>
|
|
1919
|
+
</div>
|
|
1920
|
+
),
|
|
1921
|
+
play: async ({ canvasElement, canvas, userEvent }) => {
|
|
1922
|
+
const lastRow = () => [...canvasElement.querySelectorAll("tbody tr")].at(-1);
|
|
1923
|
+
await expect(lastRow()).toHaveTextContent("Average");
|
|
1924
|
+
await userEvent.click(canvas.getByRole("button", { name: /Next/i }));
|
|
1925
|
+
await expect(lastRow()).toHaveTextContent("Average");
|
|
1926
|
+
await userEvent.click(canvas.getByRole("button", { name: /Previous/i }));
|
|
1927
|
+
await userEvent.click(canvas.getByRole("button", { name: "Sort by Rides, not sorted" }));
|
|
1928
|
+
await expect(lastRow()).toHaveTextContent("Average");
|
|
1929
|
+
const first = canvasElement.querySelector("tbody tr");
|
|
1930
|
+
// Lyon has the most rides; it is 2nd in the data, so its rank stays 2.
|
|
1931
|
+
await expect(first).toHaveTextContent("Lyon");
|
|
1932
|
+
await expect(first?.querySelector('[data-slot="data-table-rank-cell"]')).toHaveTextContent("2");
|
|
1933
|
+
},
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* `mergeEmptyHeaders` spans an ungrouped column's header down through the
|
|
1938
|
+
* empty group row, so a two-row header reads as one block.
|
|
1939
|
+
*/
|
|
1940
|
+
export const DoubleHeader: Story = {
|
|
1941
|
+
render: () => (
|
|
1942
|
+
<div className="w-full max-w-[720px]">
|
|
1943
|
+
<DataTable<CityStat, unknown>
|
|
1944
|
+
columns={[
|
|
1945
|
+
{ accessorKey: "city", header: "City" },
|
|
1946
|
+
{
|
|
1947
|
+
id: "h1",
|
|
1948
|
+
header: "First half",
|
|
1949
|
+
columns: [
|
|
1950
|
+
{ accessorKey: "q1", header: "Q1", meta: { numeric: true } },
|
|
1951
|
+
{ accessorKey: "q2", header: "Q2", meta: { numeric: true } },
|
|
1952
|
+
],
|
|
1953
|
+
},
|
|
1954
|
+
{
|
|
1955
|
+
id: "h2",
|
|
1956
|
+
header: "Second half",
|
|
1957
|
+
columns: [
|
|
1958
|
+
{ accessorKey: "q3", header: "Q3", meta: { numeric: true } },
|
|
1959
|
+
{ accessorKey: "q4", header: "Q4", meta: { numeric: true } },
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
]}
|
|
1963
|
+
data={cityStats}
|
|
1964
|
+
getRowId={(row) => row.city}
|
|
1965
|
+
mergeEmptyHeaders
|
|
1966
|
+
/>
|
|
1967
|
+
</div>
|
|
1968
|
+
),
|
|
1969
|
+
play: async ({ canvas }) => {
|
|
1970
|
+
await expect(canvas.getByRole("columnheader", { name: "City" })).toHaveAttribute(
|
|
1971
|
+
"rowspan",
|
|
1972
|
+
"2",
|
|
1973
|
+
);
|
|
1974
|
+
await expect(canvas.getByRole("columnheader", { name: "First half" })).toHaveAttribute(
|
|
1975
|
+
"colspan",
|
|
1976
|
+
"2",
|
|
1977
|
+
);
|
|
1978
|
+
},
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
/** `density="compact"` tightens the header and row padding for dense tables. */
|
|
1982
|
+
export const Compact: Story = {
|
|
1983
|
+
render: () => (
|
|
1984
|
+
<div className="w-full max-w-[640px]">
|
|
1985
|
+
<DataTable
|
|
1986
|
+
columns={responsiveColumns}
|
|
1987
|
+
data={cityStats}
|
|
1988
|
+
getRowId={(row) => row.city}
|
|
1989
|
+
density="compact"
|
|
1990
|
+
/>
|
|
1991
|
+
</div>
|
|
1992
|
+
),
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
function ExactSearchDemo() {
|
|
1996
|
+
const [search, setSearch] = useState("");
|
|
1997
|
+
return (
|
|
1998
|
+
<div className="w-full max-w-[640px]">
|
|
1999
|
+
<DataTable
|
|
2000
|
+
columns={responsiveColumns}
|
|
2001
|
+
data={cityStats}
|
|
2002
|
+
getRowId={(row) => row.city}
|
|
2003
|
+
globalFilter={search}
|
|
2004
|
+
onGlobalFilterChange={setSearch}
|
|
2005
|
+
searchMode="exact"
|
|
2006
|
+
toolbar={() => (
|
|
2007
|
+
<FilterBar>
|
|
2008
|
+
<SearchInput value={search} onValueChange={setSearch} placeholder="Exact city…" />
|
|
2009
|
+
</FilterBar>
|
|
2010
|
+
)}
|
|
2011
|
+
/>
|
|
2012
|
+
</div>
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* `searchMode="exact"` matches a whole cell value (case-insensitive) instead of
|
|
2018
|
+
* a substring: "lyon" finds Lyon, "ly" finds nothing.
|
|
2019
|
+
*/
|
|
2020
|
+
export const ExactSearch: Story = {
|
|
2021
|
+
render: () => <ExactSearchDemo />,
|
|
2022
|
+
play: async ({ canvasElement, canvas, userEvent }) => {
|
|
2023
|
+
const input = canvas.getByPlaceholderText("Exact city…");
|
|
2024
|
+
await userEvent.type(input, "ly");
|
|
2025
|
+
await waitFor(() => expect(canvas.getByText("No results.")).toBeInTheDocument());
|
|
2026
|
+
await userEvent.type(input, "on");
|
|
2027
|
+
await waitFor(() => expect(canvasElement.querySelectorAll("tbody tr")).toHaveLength(1));
|
|
2028
|
+
await expect(canvasElement.querySelector("tbody tr")).toHaveTextContent("Lyon");
|
|
2029
|
+
},
|
|
2030
|
+
};
|