@elabs-ai/components-data 4.2.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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-3OVN7MMO.js +2 -0
  3. package/dist/chunk-3OVN7MMO.js.map +1 -0
  4. package/dist/chunk-3UFWYVDS.js +2 -0
  5. package/dist/chunk-3UFWYVDS.js.map +1 -0
  6. package/dist/chunk-47ZBQZYS.js +47 -0
  7. package/dist/chunk-47ZBQZYS.js.map +1 -0
  8. package/dist/chunk-5K2IFOV2.js +2 -0
  9. package/dist/chunk-5K2IFOV2.js.map +1 -0
  10. package/dist/chunk-677ORWI6.js +17 -0
  11. package/dist/chunk-677ORWI6.js.map +1 -0
  12. package/dist/chunk-7ZYUY3UL.js +2 -0
  13. package/dist/chunk-7ZYUY3UL.js.map +1 -0
  14. package/dist/chunk-BEYHV3ZX.js +37 -0
  15. package/dist/chunk-BEYHV3ZX.js.map +1 -0
  16. package/dist/chunk-DBPJ7LVM.js +57 -0
  17. package/dist/chunk-DBPJ7LVM.js.map +1 -0
  18. package/dist/chunk-E3V3SYY7.js +87 -0
  19. package/dist/chunk-E3V3SYY7.js.map +1 -0
  20. package/dist/chunk-GBO56756.js +81 -0
  21. package/dist/chunk-GBO56756.js.map +1 -0
  22. package/dist/chunk-HG2YMSRM.js +95 -0
  23. package/dist/chunk-HG2YMSRM.js.map +1 -0
  24. package/dist/chunk-K2GHSRCP.js +1823 -0
  25. package/dist/chunk-K2GHSRCP.js.map +1 -0
  26. package/dist/chunk-KCB33PD3.js +43 -0
  27. package/dist/chunk-KCB33PD3.js.map +1 -0
  28. package/dist/chunk-MV2YVXEZ.js +67 -0
  29. package/dist/chunk-MV2YVXEZ.js.map +1 -0
  30. package/dist/chunk-NKVJUQYW.js +82 -0
  31. package/dist/chunk-NKVJUQYW.js.map +1 -0
  32. package/dist/chunk-OASM53LL.js +29 -0
  33. package/dist/chunk-OASM53LL.js.map +1 -0
  34. package/dist/chunk-RZ5MYZOA.js +2 -0
  35. package/dist/chunk-RZ5MYZOA.js.map +1 -0
  36. package/dist/chunk-SK6347WB.js +100 -0
  37. package/dist/chunk-SK6347WB.js.map +1 -0
  38. package/dist/chunk-TBB33PHQ.js +58 -0
  39. package/dist/chunk-TBB33PHQ.js.map +1 -0
  40. package/dist/chunk-VCLA45IC.js +150 -0
  41. package/dist/chunk-VCLA45IC.js.map +1 -0
  42. package/dist/chunk-XN6O6Z2N.js +58 -0
  43. package/dist/chunk-XN6O6Z2N.js.map +1 -0
  44. package/dist/chunk-ZFEW36RG.js +77 -0
  45. package/dist/chunk-ZFEW36RG.js.map +1 -0
  46. package/dist/chunk-ZQXJ34VJ.js +34 -0
  47. package/dist/chunk-ZQXJ34VJ.js.map +1 -0
  48. package/dist/chunk-ZUCGV2XD.js +42 -0
  49. package/dist/chunk-ZUCGV2XD.js.map +1 -0
  50. package/dist/column-picker/column-picker.js +8 -0
  51. package/dist/column-picker/column-picker.js.map +1 -0
  52. package/dist/column-picker/index.js +9 -0
  53. package/dist/column-picker/index.js.map +1 -0
  54. package/dist/data-table/card-layout.js +11 -0
  55. package/dist/data-table/card-layout.js.map +1 -0
  56. package/dist/data-table/cell-scales.js +26 -0
  57. package/dist/data-table/cell-scales.js.map +1 -0
  58. package/dist/data-table/cells/bar-cell.js +14 -0
  59. package/dist/data-table/cells/bar-cell.js.map +1 -0
  60. package/dist/data-table/cells/category-legend.js +9 -0
  61. package/dist/data-table/cells/category-legend.js.map +1 -0
  62. package/dist/data-table/cells/columns-cell.js +13 -0
  63. package/dist/data-table/cells/columns-cell.js.map +1 -0
  64. package/dist/data-table/cells/heatmap-cell.js +13 -0
  65. package/dist/data-table/cells/heatmap-cell.js.map +1 -0
  66. package/dist/data-table/cells/markdown-cell.js +13 -0
  67. package/dist/data-table/cells/markdown-cell.js.map +1 -0
  68. package/dist/data-table/cells/sparkline-cell.js +13 -0
  69. package/dist/data-table/cells/sparkline-cell.js.map +1 -0
  70. package/dist/data-table/column-meta.js +12 -0
  71. package/dist/data-table/column-meta.js.map +1 -0
  72. package/dist/data-table/data-table.js +23 -0
  73. package/dist/data-table/data-table.js.map +1 -0
  74. package/dist/data-table/index.js +23 -0
  75. package/dist/data-table/index.js.map +1 -0
  76. package/dist/data-table/ranks-column.js +13 -0
  77. package/dist/data-table/ranks-column.js.map +1 -0
  78. package/dist/data-table/sticky-rows.js +10 -0
  79. package/dist/data-table/sticky-rows.js.map +1 -0
  80. package/dist/data-table/use-table-breakpoint.js +13 -0
  81. package/dist/data-table/use-table-breakpoint.js.map +1 -0
  82. package/dist/facet-filter/facet-filter.js +8 -0
  83. package/dist/facet-filter/facet-filter.js.map +1 -0
  84. package/dist/facet-filter/index.js +9 -0
  85. package/dist/facet-filter/index.js.map +1 -0
  86. package/dist/filter-bar/filter-bar.js +8 -0
  87. package/dist/filter-bar/filter-bar.js.map +1 -0
  88. package/dist/filter-bar/filter-chip.js +9 -0
  89. package/dist/filter-bar/filter-chip.js.map +1 -0
  90. package/dist/filter-bar/index.js +13 -0
  91. package/dist/filter-bar/index.js.map +1 -0
  92. package/dist/index.d.ts +547 -8
  93. package/dist/index.js +102 -1512
  94. package/dist/index.js.map +1 -1
  95. package/dist/search-input/index.js +9 -0
  96. package/dist/search-input/index.js.map +1 -0
  97. package/dist/search-input/search-input.js +9 -0
  98. package/dist/search-input/search-input.js.map +1 -0
  99. package/dist/to-csv.js +10 -0
  100. package/dist/to-csv.js.map +1 -0
  101. package/package.json +7 -7
  102. package/src/data-table/card-layout.test.tsx +26 -0
  103. package/src/data-table/card-layout.tsx +74 -0
  104. package/src/data-table/cell-scales.test.ts +118 -0
  105. package/src/data-table/cell-scales.ts +246 -0
  106. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  107. package/src/data-table/cells/bar-cell.tsx +135 -0
  108. package/src/data-table/cells/category-legend.test.tsx +37 -0
  109. package/src/data-table/cells/category-legend.tsx +58 -0
  110. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  111. package/src/data-table/cells/columns-cell.tsx +79 -0
  112. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  113. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  114. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  115. package/src/data-table/cells/markdown-cell.tsx +91 -0
  116. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  117. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  118. package/src/data-table/column-meta.test.ts +42 -0
  119. package/src/data-table/column-meta.ts +282 -0
  120. package/src/data-table/data-table.stories.tsx +733 -1
  121. package/src/data-table/data-table.test.tsx +486 -0
  122. package/src/data-table/data-table.tsx +965 -165
  123. package/src/data-table/ranks-column.test.tsx +65 -0
  124. package/src/data-table/ranks-column.tsx +91 -0
  125. package/src/data-table/sticky-rows.test.ts +16 -0
  126. package/src/data-table/sticky-rows.ts +77 -0
  127. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  128. package/src/data-table/use-table-breakpoint.ts +67 -0
  129. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  130. package/src/index.ts +64 -0
  131. package/src/templates-data-app.stories.tsx +7 -2
@@ -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,
@@ -901,6 +902,7 @@ export const WithColumnResizing: Story = {
901
902
  * hide from a comfortable-only check.
902
903
  */
903
904
  export const WithColumnResizingCompactDensity: Story = {
905
+ tags: ["!dev"],
904
906
  parameters: {
905
907
  docs: {
906
908
  description: {
@@ -999,6 +1001,7 @@ export const WithColumnResizingRealDoubleClick: Story = {
999
1001
  * growing) should still be caught on the unproblematic column too.
1000
1002
  */
1001
1003
  export const WithColumnResizingSortToggleHitTest: Story = {
1004
+ name: "Column resizing — sort button vs. resize handle",
1002
1005
  parameters: {
1003
1006
  docs: {
1004
1007
  description: {
@@ -1157,6 +1160,7 @@ export const RowSelectionWithToolbar: Story = {
1157
1160
  * `FocusRingClearanceDark`.
1158
1161
  */
1159
1162
  export const RowSelectionWithToolbarDark: Story = {
1163
+ tags: ["!dev"],
1160
1164
  name: "Row selection — with toolbar (dark)",
1161
1165
  globals: { theme: "dark" },
1162
1166
  render: RowSelectionWithToolbar.render,
@@ -1166,6 +1170,53 @@ export const RowSelectionWithToolbarDark: Story = {
1166
1170
  },
1167
1171
  };
1168
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
+
1169
1220
  // ─── RowReorder (#13) ───────────────────────────────────────────────────────
1170
1221
 
1171
1222
  /**
@@ -1296,3 +1347,684 @@ export const RowReorderWholeRow: Story = {
1296
1347
  });
1297
1348
  },
1298
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
+ };