@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.
Files changed (139) 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 +554 -10
  93. package/dist/index.js +102 -1494
  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/__contract__/filter-chip.contract.test.tsx +49 -0
  103. package/src/column-picker/column-picker.tsx +3 -2
  104. package/src/data-table/card-layout.test.tsx +26 -0
  105. package/src/data-table/card-layout.tsx +74 -0
  106. package/src/data-table/cell-scales.test.ts +118 -0
  107. package/src/data-table/cell-scales.ts +246 -0
  108. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  109. package/src/data-table/cells/bar-cell.tsx +135 -0
  110. package/src/data-table/cells/category-legend.test.tsx +37 -0
  111. package/src/data-table/cells/category-legend.tsx +58 -0
  112. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  113. package/src/data-table/cells/columns-cell.tsx +79 -0
  114. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  115. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  116. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  117. package/src/data-table/cells/markdown-cell.tsx +91 -0
  118. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  119. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  120. package/src/data-table/column-meta.test.ts +42 -0
  121. package/src/data-table/column-meta.ts +282 -0
  122. package/src/data-table/data-table.stories.tsx +748 -1
  123. package/src/data-table/data-table.test.tsx +547 -11
  124. package/src/data-table/data-table.tsx +1014 -175
  125. package/src/data-table/ranks-column.test.tsx +65 -0
  126. package/src/data-table/ranks-column.tsx +91 -0
  127. package/src/data-table/sticky-rows.test.ts +16 -0
  128. package/src/data-table/sticky-rows.ts +77 -0
  129. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  130. package/src/data-table/use-table-breakpoint.ts +67 -0
  131. package/src/facet-filter/facet-filter.stories.tsx +4 -1
  132. package/src/facet-filter/facet-filter.test.tsx +3 -3
  133. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  134. package/src/index.ts +64 -0
  135. package/src/search-input/search-input.test.tsx +35 -0
  136. package/src/search-input/search-input.tsx +44 -19
  137. package/src/templates-data-app.stories.tsx +7 -2
  138. package/src/to-csv.test.ts +13 -0
  139. package/src/to-csv.ts +7 -30
@@ -537,6 +537,39 @@ describe("DataTable — virtualized a11y + composability", () => {
537
537
  expect(indexedRows.length).toBeLessThan(bigData.length);
538
538
  });
539
539
 
540
+ it("places sticky rows in the virtualized aria-rowindex sequence and counts them", () => {
541
+ // Sticky rows mount OUTSIDE the virtual window. Without an index of their
542
+ // own AT hears unplaced extra rows, and aria-rowcount (built from the
543
+ // centre row model) under-reports the table by exactly those rows.
544
+ const { container } = render(
545
+ <DataTable
546
+ columns={columns}
547
+ data={bigData}
548
+ enableRowVirtualization
549
+ stickyRows={(row) => {
550
+ if (row.name === "Row 0") return "top";
551
+ if (row.name === "Row 1") return "bottom";
552
+ return undefined;
553
+ }}
554
+ />,
555
+ );
556
+ // 98 centre + 1 top + 1 bottom + 1 header row.
557
+ expect(container.querySelector("table")).toHaveAttribute(
558
+ "aria-rowcount",
559
+ String(bigData.length + 1),
560
+ );
561
+ const mounted = [...container.querySelectorAll("tbody tr")].filter(
562
+ (tr) => tr.getAttribute("aria-hidden") !== "true",
563
+ );
564
+ // Every mounted, non-spacer row is placed — no row without an index.
565
+ expect(mounted.every((tr) => tr.hasAttribute("aria-rowindex"))).toBe(true);
566
+ const indexOf = (name: string) =>
567
+ mounted.find((tr) => tr.textContent?.includes(name))?.getAttribute("aria-rowindex");
568
+ // Header holds 1; the top-pinned row takes 2, the bottom-pinned row last.
569
+ expect(indexOf("Row 0")).toBe("2");
570
+ expect(indexOf("Row 1")).toBe(String(bigData.length + 1));
571
+ });
572
+
540
573
  it("makes the virtualized scroll region keyboard-focusable with a visible focus ring", () => {
541
574
  const { container } = render(
542
575
  <DataTable columns={columns} data={bigData} enableRowVirtualization />,
@@ -592,11 +625,35 @@ describe("DataTable — zebra striping (default) vs lines", () => {
592
625
  const rows = container.querySelectorAll("tbody tr");
593
626
  expect(rows.length).toBe(3);
594
627
  // 2nd row (index 1) is striped; 1st/3rd are not — the stripe is the cue.
595
- expect(rows[0]?.className).not.toContain("bg-foreground/5");
596
- expect(rows[1]?.className).toContain("bg-foreground/5");
597
- expect(rows[2]?.className).not.toContain("bg-foreground/5");
598
- // No row carries a divider (a border on a striped region would be redundant).
599
- rows.forEach((r) => expect(r.className).not.toContain("border-b"));
628
+ expect(rows[0]?.className).not.toContain("bg-table-stripe");
629
+ expect(rows[1]?.className).toContain("bg-table-stripe");
630
+ expect(rows[2]?.className).not.toContain("bg-table-stripe");
631
+ // No row carries a fixed divider (a border on a striped region would be
632
+ // redundant). The only row rule is the theme-gated width, `0px` by default,
633
+ // for a theme that turns the stripe off.
634
+ rows.forEach((r) => {
635
+ const classes = r.className.split(/\s+/);
636
+ expect(classes).not.toContain("border-b");
637
+ expect(classes).toContain("border-b-(length:--table-row-rule-width)");
638
+ });
639
+ });
640
+
641
+ it("draws column dividers only when asked", () => {
642
+ const plain = render(<DataTable columns={columns} data={data} />);
643
+ plain.container
644
+ .querySelectorAll("th, td")
645
+ .forEach((c) => expect(c.className.split(/\s+/)).not.toContain("border-e"));
646
+ plain.unmount();
647
+
648
+ const { container } = render(<DataTable columns={columns} data={data} columnDividers />);
649
+ const cells = container.querySelectorAll("thead th, tbody td");
650
+ expect(cells.length).toBeGreaterThan(0);
651
+ cells.forEach((c) => {
652
+ const classes = c.className.split(/\s+/);
653
+ expect(classes).toContain("border-e");
654
+ expect(classes).toContain("border-rule");
655
+ expect(classes).toContain("last:border-e-0");
656
+ });
600
657
  });
601
658
 
602
659
  it("draws border-strong dividers and no stripes when zebra is disabled", () => {
@@ -605,7 +662,7 @@ describe("DataTable — zebra striping (default) vs lines", () => {
605
662
  rows.forEach((r) => {
606
663
  expect(r.className).toContain("border-b");
607
664
  expect(r.className).toContain("border-border-strong");
608
- expect(r.className).not.toContain("bg-foreground/5");
665
+ expect(r.className).not.toContain("bg-table-stripe");
609
666
  });
610
667
  // Last row drops its divider so it doesn't double with the container border.
611
668
  expect(rows[rows.length - 1]?.className).toContain("last:border-b-0");
@@ -866,6 +923,32 @@ describe("DataTable — #330 the scroll tab stop exists only while the region ov
866
923
  simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 900, scrollLeft: 0 });
867
924
  expect(scrollRegion).not.toHaveAttribute("tabindex");
868
925
  });
926
+
927
+ it('gains role="group" once it overflows — a named, non-landmark stop', () => {
928
+ const { container } = render(<DataTable columns={columns} data={data} />);
929
+ const scrollRegion = scrollRegionOf(container);
930
+ // `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
931
+ // produce an accessible name at all — the stop needs a naming-capable
932
+ // role, and `group` (not the `region` landmark, which collides under axe
933
+ // `landmark-unique` across tables) is it.
934
+ expect(scrollRegion).not.toHaveAttribute("role");
935
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 300, scrollLeft: 0 });
936
+ expect(scrollRegion).toHaveAttribute("role", "group");
937
+ });
938
+ });
939
+
940
+ describe("DataTable — virtualized scroll region has a naming-capable role", () => {
941
+ it('the always-focusable virtualized scroll container carries role="group"', () => {
942
+ const { container } = render(
943
+ <DataTable columns={columns} data={data} enableRowVirtualization />,
944
+ );
945
+ const scroll = container.querySelector(".overflow-auto");
946
+ expect(scroll).toHaveAttribute("tabindex", "0");
947
+ expect(scroll).toHaveAttribute("aria-label");
948
+ // Same reasoning as the non-virtualized branch above: `aria-label` alone
949
+ // on a role-less `<div>` is not guaranteed to compute an accessible name.
950
+ expect(scroll).toHaveAttribute("role", "group");
951
+ });
869
952
  });
870
953
 
871
954
  describe("DataTable — #330 horizontal-scroll edge-fade affordance", () => {
@@ -1177,7 +1260,7 @@ describe("DataTable — #337 onRowClick + rowClassName", () => {
1177
1260
  const betaRow = screen.getByText("Beta").closest("tr")!;
1178
1261
  expect(betaRow).toHaveClass("is-highlighted");
1179
1262
  // Beta is row index 1 — the zebra stripe class must still be present.
1180
- expect(betaRow.className).toContain("bg-foreground/5");
1263
+ expect(betaRow.className).toContain("bg-table-stripe");
1181
1264
  });
1182
1265
 
1183
1266
  it("gives a clickable row a pointer cursor and a focus ring driven by its activation button", () => {
@@ -1393,10 +1476,10 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
1393
1476
  expect(odd!.className).toContain("bg-card");
1394
1477
  // Only the striped row re-applies the wash, on the decorative ::before layer
1395
1478
  // — this is the bug #333 reports: a single opaque fill erased the stripe.
1396
- expect(odd!.className).toContain("before:bg-foreground/5");
1397
- expect(even!.className).not.toContain("before:bg-foreground/5");
1479
+ expect(odd!.className).toContain("before:bg-table-stripe");
1480
+ expect(even!.className).not.toContain("before:bg-table-stripe");
1398
1481
  // Hover/selected are re-applied from the row group in both cases.
1399
- expect(even!.className).toContain("group-hover/row:before:bg-foreground/10");
1482
+ expect(even!.className).toContain("group-hover/row:before:bg-table-row-hover");
1400
1483
  expect(container.querySelector("tbody tr")!.className).toContain("group/row");
1401
1484
  });
1402
1485
 
@@ -1411,7 +1494,7 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
1411
1494
  );
1412
1495
  for (const cell of pinned(container, "left")) {
1413
1496
  expect(cell.className).toContain("bg-card");
1414
- expect(cell.className).not.toContain("before:bg-foreground/5");
1497
+ expect(cell.className).not.toContain("before:bg-table-stripe");
1415
1498
  }
1416
1499
  // The row divider is still the separation cue and is untouched by pinning.
1417
1500
  expect(container.querySelector("tbody tr")!.className).toContain("border-border-strong");
@@ -2875,3 +2958,456 @@ describe("DataTable — #13 row drag-reorder", () => {
2875
2958
  expect(describedByText).toMatch(/To pick up|space bar/i);
2876
2959
  });
2877
2960
  });
2961
+
2962
+ // ─── Presentation layer: visuals, format, showAt, cards, sticky rows, ranks ──
2963
+
2964
+ /** Every element measures `width` wide, so the table breakpoint resolves from it. */
2965
+ function mockTableWidth(width: number) {
2966
+ return vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
2967
+ () =>
2968
+ ({
2969
+ width,
2970
+ height: 40,
2971
+ top: 0,
2972
+ left: 0,
2973
+ right: width,
2974
+ bottom: 40,
2975
+ x: 0,
2976
+ y: 0,
2977
+ toJSON: () => ({}),
2978
+ }) as DOMRect,
2979
+ );
2980
+ }
2981
+
2982
+ interface CityRow {
2983
+ city: string;
2984
+ rides: number;
2985
+ change: number;
2986
+ q1: number;
2987
+ q2: number;
2988
+ q3: number;
2989
+ region: string;
2990
+ }
2991
+
2992
+ const cities: CityRow[] = [
2993
+ { city: "Oslo", rides: 20, change: -5, q1: 1, q2: 4, q3: 2, region: "North" },
2994
+ { city: "Lyon", rides: 40, change: 20, q1: 8, q2: 3, q3: 5, region: "South" },
2995
+ { city: "Graz", rides: 9, change: 5, q1: 2, q2: 2, q3: 8, region: "South" },
2996
+ { city: "Average", rides: 23, change: 6.7, q1: 3.7, q2: 3, q3: 5, region: "All" },
2997
+ ];
2998
+
2999
+ describe("DataTable — presentation layer", () => {
3000
+ it("no presentation prop: the default DOM is unchanged (table, no rank, no layout attr)", () => {
3001
+ const { container } = render(<DataTable columns={columns} data={data} />);
3002
+ expect(container.querySelector("table")).not.toBeNull();
3003
+ expect(container.querySelector("dl")).toBeNull();
3004
+ expect(container.querySelector("[data-layout]")).toBeNull();
3005
+ expect(container.querySelector('[data-slot="data-table-rank-cell"]')).toBeNull();
3006
+ });
3007
+
3008
+ it('layout="auto": <dl> cards under 450 px, a <table> above', () => {
3009
+ const narrow = mockTableWidth(380);
3010
+ const { container, unmount } = render(
3011
+ <DataTable columns={columns} data={data} layout="auto" aria-label="Rows" />,
3012
+ );
3013
+ expect(container.querySelector('[data-layout="cards"]')).not.toBeNull();
3014
+ expect(container.querySelector("table")).toBeNull();
3015
+ expect(container.querySelectorAll('[data-slot="data-table-card"] dl')).toHaveLength(3);
3016
+ expect(screen.getAllByRole("term").map((t) => t.textContent)).toContain("Name");
3017
+ unmount();
3018
+ narrow.mockRestore();
3019
+ const wide = mockTableWidth(900);
3020
+ const again = render(<DataTable columns={columns} data={data} layout="auto" />);
3021
+ expect(again.container.querySelector('[data-layout="table"]')).not.toBeNull();
3022
+ expect(again.container.querySelector("table")).not.toBeNull();
3023
+ expect(again.container.querySelector("dl")).toBeNull();
3024
+ wide.mockRestore();
3025
+ });
3026
+
3027
+ it("cards keep sorting: the sort bar reorders the cards", () => {
3028
+ const { container } = render(<DataTable columns={columns} data={data} layout="cards" />);
3029
+ const firstName = () =>
3030
+ container.querySelector('[data-slot="data-table-card"] dd')?.textContent ?? "";
3031
+ expect(firstName()).toBe("Alpha");
3032
+ // TanStack sorts a numeric column descending first.
3033
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Value, not sorted" }));
3034
+ expect(firstName()).toBe("Alpha");
3035
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Value, descending" }));
3036
+ expect(firstName()).toBe("Beta");
3037
+ });
3038
+
3039
+ it("showAt { base: true, narrow: false } hides the column under 450 px only", () => {
3040
+ const cols: ColumnDef<Row>[] = [
3041
+ columns[0]!,
3042
+ { ...columns[1]!, meta: { showAt: { base: true, narrow: false } } },
3043
+ ];
3044
+ const narrow = mockTableWidth(380);
3045
+ const { unmount } = render(<DataTable columns={cols} data={data} />);
3046
+ expect(screen.queryByRole("columnheader", { name: /Value/ })).toBeNull();
3047
+ expect(screen.getAllByRole("columnheader")).toHaveLength(1);
3048
+ unmount();
3049
+ narrow.mockRestore();
3050
+ const wide = mockTableWidth(900);
3051
+ render(<DataTable columns={cols} data={data} />);
3052
+ expect(screen.getByRole("columnheader", { name: /Value/ })).toBeInTheDocument();
3053
+ wide.mockRestore();
3054
+ });
3055
+
3056
+ it("warns once, instead of failing silently, when row reorder meets the card layout", () => {
3057
+ const cols: ColumnDef<CityRow>[] = [
3058
+ { accessorKey: "city", header: "City" },
3059
+ { accessorKey: "rides", header: "Rides" },
3060
+ ];
3061
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
3062
+ const onRowReorder = vi.fn();
3063
+ const { container, rerender } = render(
3064
+ <DataTable
3065
+ columns={cols}
3066
+ data={cities}
3067
+ enableRowReorder
3068
+ layout="cards"
3069
+ onRowReorder={onRowReorder}
3070
+ />,
3071
+ );
3072
+ // No drag affordance is mounted in cards — no grip button, no drop target.
3073
+ expect(screen.queryByRole("button", { name: /reorder|drag|move/i })).not.toBeInTheDocument();
3074
+ expect(container.querySelector("[data-slot='data-table-card-region']")).not.toBeNull();
3075
+ expect(onRowReorder).not.toHaveBeenCalled();
3076
+ const reorderWarnings = () =>
3077
+ warn.mock.calls.filter((c) => String(c[0]).includes("`enableRowReorder` is ignored"));
3078
+ expect(reorderWarnings()).toHaveLength(1);
3079
+ // Once per mount, not once per render.
3080
+ rerender(
3081
+ <DataTable
3082
+ columns={cols}
3083
+ data={cities}
3084
+ enableRowReorder
3085
+ layout="cards"
3086
+ onRowReorder={onRowReorder}
3087
+ />,
3088
+ );
3089
+ expect(reorderWarnings()).toHaveLength(1);
3090
+ warn.mockRestore();
3091
+ });
3092
+
3093
+ it("stickyRows keeps the average row on every page and after sorting", () => {
3094
+ const cols: ColumnDef<CityRow>[] = [
3095
+ { accessorKey: "city", header: "City" },
3096
+ { accessorKey: "rides", header: "Rides", enableSorting: true },
3097
+ ];
3098
+ const { container } = render(
3099
+ <DataTable
3100
+ columns={cols}
3101
+ data={cities}
3102
+ enablePagination
3103
+ pageSize={2}
3104
+ stickyRows={(row) => (row.city === "Average" ? "bottom" : undefined)}
3105
+ />,
3106
+ );
3107
+ const names = () =>
3108
+ [...container.querySelectorAll("tbody tr")].map(
3109
+ (tr) => tr.querySelector("td")?.textContent ?? "",
3110
+ );
3111
+ expect(names()).toEqual(["Oslo", "Lyon", "Average"]);
3112
+ fireEvent.click(screen.getByRole("button", { name: /Next/i }));
3113
+ expect(names()).toEqual(["Graz", "Average"]);
3114
+ fireEvent.click(screen.getByRole("button", { name: /Previous/i }));
3115
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Rides, not sorted" }));
3116
+ // Descending (numeric columns sort descending first): Lyon 40, Oslo 20 on
3117
+ // page one — the average (23) is not sorted into them, it stays last.
3118
+ expect(names()).toEqual(["Lyon", "Oslo", "Average"]);
3119
+ fireEvent.click(screen.getByRole("button", { name: /Next/i }));
3120
+ expect(names()).toEqual(["Graz", "Average"]);
3121
+ expect(container.querySelector('tr[data-sticky="bottom"]')).toHaveTextContent("Average");
3122
+ });
3123
+
3124
+ it("showRanks renders 1…n in data order, unaffected by sort (sticky rows unranked)", () => {
3125
+ const cols: ColumnDef<CityRow>[] = [
3126
+ { accessorKey: "city", header: "City" },
3127
+ { accessorKey: "rides", header: "Rides", enableSorting: true },
3128
+ ];
3129
+ const { container } = render(
3130
+ <DataTable
3131
+ columns={cols}
3132
+ data={cities}
3133
+ showRanks
3134
+ stickyRows={(row) => (row.city === "Average" ? "bottom" : undefined)}
3135
+ />,
3136
+ );
3137
+ const ranked = () =>
3138
+ [...container.querySelectorAll("tbody tr")].map((tr) => [
3139
+ tr.querySelector('[data-slot="data-table-rank-cell"]')?.textContent ?? "",
3140
+ tr.querySelectorAll("td")[1]?.textContent ?? "",
3141
+ ]);
3142
+ expect(ranked()).toEqual([
3143
+ ["1", "Oslo"],
3144
+ ["2", "Lyon"],
3145
+ ["3", "Graz"],
3146
+ ["", "Average"],
3147
+ ]);
3148
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Rides, not sorted" }));
3149
+ expect(ranked()).toEqual([
3150
+ ["2", "Lyon"],
3151
+ ["1", "Oslo"],
3152
+ ["3", "Graz"],
3153
+ ["", "Average"],
3154
+ ]);
3155
+ expect(screen.getAllByRole("columnheader")[0]).toHaveTextContent("#");
3156
+ });
3157
+
3158
+ /**
3159
+ * b-5 — the same table, read: the ranks are the DATA order, so after the
3160
+ * sort above they read 2, 1, 3 beside a descending column. That is only
3161
+ * honest if the table says which position it is printing, in BOTH channels
3162
+ * — the header's accessible name for AT, and a printed key for everyone
3163
+ * else. A bare "#" said it in neither.
3164
+ */
3165
+ it("showRanks says which position it prints — named for AT, printed as a key", () => {
3166
+ const cols: ColumnDef<CityRow>[] = [
3167
+ { accessorKey: "city", header: "City" },
3168
+ { accessorKey: "rides", header: "Rides", enableSorting: true },
3169
+ ];
3170
+ const { container } = render(<DataTable columns={cols} data={cities} showRanks />);
3171
+ expect(screen.getAllByRole("columnheader")[0]).toHaveAccessibleName(
3172
+ "Position in the data as supplied",
3173
+ );
3174
+ const key = container.querySelector('[data-slot="data-table-rank-key"]');
3175
+ expect(key).toHaveTextContent(
3176
+ "# is each row’s position in the data as supplied, not its position in this view — sorting never renumbers it.",
3177
+ );
3178
+ });
3179
+
3180
+ it("showRanks: a caller's own rankLabel names the column and the key", () => {
3181
+ const cols: ColumnDef<CityRow>[] = [{ accessorKey: "city", header: "City" }];
3182
+ const { container } = render(
3183
+ <DataTable columns={cols} data={cities} showRanks rankLabel="Rank in the 2024 census" />,
3184
+ );
3185
+ expect(screen.getAllByRole("columnheader")[0]).toHaveAccessibleName("Rank in the 2024 census");
3186
+ expect(container.querySelector('[data-slot="data-table-rank-key"]')).toHaveTextContent(
3187
+ "# — Rank in the 2024 census",
3188
+ );
3189
+ });
3190
+
3191
+ it("no ranks, no key: the rank key never renders for a table without showRanks", () => {
3192
+ const cols: ColumnDef<CityRow>[] = [{ accessorKey: "city", header: "City" }];
3193
+ const { container } = render(<DataTable columns={cols} data={cities} />);
3194
+ expect(container.querySelector('[data-slot="data-table-rank-key"]')).toBeNull();
3195
+ expect(container.querySelector('[data-slot="data-table-legends"]')).toBeNull();
3196
+ });
3197
+
3198
+ it('searchMode="exact" matches whole, case-insensitive values only', () => {
3199
+ const { container, rerender } = render(
3200
+ <DataTable columns={columns} data={data} initialView={{ globalFilter: "et" }} />,
3201
+ );
3202
+ expect(container.querySelectorAll("tbody tr")).toHaveLength(1);
3203
+ rerender(<DataTable columns={columns} data={data} globalFilter="beta" searchMode="exact" />);
3204
+ expect(container.querySelectorAll("tbody tr")).toHaveLength(1);
3205
+ expect(container.querySelector("tbody tr")).toHaveTextContent("Beta");
3206
+ rerender(<DataTable columns={columns} data={data} globalFilter="bet" searchMode="exact" />);
3207
+ expect(screen.queryByText("Beta")).toBeNull();
3208
+ });
3209
+
3210
+ it("mergeEmptyHeaders spans an ungrouped column's header down the header rows", () => {
3211
+ const cols: ColumnDef<CityRow>[] = [
3212
+ { accessorKey: "city", header: "City" },
3213
+ {
3214
+ id: "quarters",
3215
+ header: "Quarters",
3216
+ columns: [
3217
+ { accessorKey: "q1", header: "Q1" },
3218
+ { accessorKey: "q2", header: "Q2" },
3219
+ ],
3220
+ },
3221
+ ];
3222
+ const { container, rerender } = render(<DataTable columns={cols} data={cities} />);
3223
+ expect(container.querySelectorAll("thead tr")[0]?.querySelectorAll("th")).toHaveLength(2);
3224
+ rerender(<DataTable columns={cols} data={cities} mergeEmptyHeaders />);
3225
+ const [top, bottom] = [...container.querySelectorAll("thead tr")];
3226
+ const city = [...(top?.querySelectorAll("th") ?? [])].find((th) => th.textContent === "City");
3227
+ expect(city).toHaveAttribute("rowspan", "2");
3228
+ expect(top?.querySelector("th[colspan='2']")).toHaveTextContent("Quarters");
3229
+ expect([...(bottom?.querySelectorAll("th") ?? [])].map((th) => th.textContent)).toEqual([
3230
+ "Q1",
3231
+ "Q2",
3232
+ ]);
3233
+ });
3234
+
3235
+ it("heatmap: ramp-token backgrounds, values kept for AT, numeric sort", () => {
3236
+ const cols: ColumnDef<CityRow>[] = [
3237
+ { accessorKey: "city", header: "City" },
3238
+ {
3239
+ accessorKey: "rides",
3240
+ header: "Rides",
3241
+ enableSorting: true,
3242
+ meta: {
3243
+ numeric: true,
3244
+ visual: { kind: "heatmap", scale: { type: "stepped", steps: 3 }, hideValue: true },
3245
+ },
3246
+ },
3247
+ ];
3248
+ const { container } = render(<DataTable columns={cols} data={cities.slice(0, 3)} />);
3249
+ const tds = [...container.querySelectorAll("tbody td:nth-child(2)")] as HTMLElement[];
3250
+ for (const td of tds) {
3251
+ expect(td.style.backgroundColor).toMatch(/^var\(--chart-seq-\d\)$/);
3252
+ expect(td.querySelector('[data-slot="heatmap-cell"]')).toHaveClass("sr-only");
3253
+ }
3254
+ expect(tds.map((td) => td.textContent)).toEqual(["20", "40", "9"]);
3255
+ const order = () =>
3256
+ [...container.querySelectorAll("tbody td:nth-child(2)")].map((td) => td.textContent);
3257
+ // Numeric, not string, order both ways ("9" would sort after "40" as text).
3258
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Rides, not sorted" }));
3259
+ expect(order()).toEqual(["40", "20", "9"]);
3260
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Rides, descending" }));
3261
+ expect(order()).toEqual(["9", "20", "40"]);
3262
+ });
3263
+
3264
+ it('bar: range "column" is proportional to the column max; negatives paint left of zero', () => {
3265
+ const cols: ColumnDef<CityRow>[] = [
3266
+ { accessorKey: "city", header: "City" },
3267
+ { accessorKey: "rides", header: "Rides", meta: { visual: { kind: "bar", track: true } } },
3268
+ { accessorKey: "change", header: "Change", meta: { visual: { kind: "bar" } } },
3269
+ ];
3270
+ const { container } = render(<DataTable columns={cols} data={cities.slice(0, 3)} />);
3271
+ const bars = (col: number) =>
3272
+ [...container.querySelectorAll(`tbody td:nth-child(${col}) [data-slot="bar-cell-bar"]`)].map(
3273
+ (el) => (el as HTMLElement).style,
3274
+ );
3275
+ expect(bars(2).map((s) => s.width)).toEqual(["50%", "100%", "22.5%"]);
3276
+ expect(container.querySelector('[data-slot="bar-cell-track"]')).toHaveClass("bg-muted");
3277
+ const [oslo] = bars(3);
3278
+ expect(oslo?.backgroundColor).toBe("var(--chart-div-neg-2)");
3279
+ expect(oslo?.insetInlineStart).toBe("0%");
3280
+ // The printed value stays in the cell for AT.
3281
+ expect(container.querySelector("tbody td:nth-child(3)")).toHaveTextContent("-5");
3282
+ });
3283
+
3284
+ it("bar: one value box per column, so bar LENGTHS compare down the column", () => {
3285
+ // Without a column-wide reservation the track is whatever each row's number
3286
+ // leaves over, so "9" would draw a longer bar than its share of the column.
3287
+ const cols: ColumnDef<CityRow>[] = [
3288
+ { accessorKey: "city", header: "City" },
3289
+ { accessorKey: "rides", header: "Rides", meta: { visual: { kind: "bar", track: true } } },
3290
+ {
3291
+ accessorKey: "change",
3292
+ header: "Change",
3293
+ meta: { format: { sign: "always", suffix: " %" }, visual: { kind: "bar" } },
3294
+ },
3295
+ ];
3296
+ const { container } = render(<DataTable columns={cols} data={cities.slice(0, 3)} />);
3297
+ const boxes = (col: number) =>
3298
+ [
3299
+ ...container.querySelectorAll(`tbody td:nth-child(${col}) [data-slot="bar-cell-value"]`),
3300
+ ].map((el) => (el as HTMLElement).style.width);
3301
+ // "20" / "40" / "9" → the column's longest label is 2 characters.
3302
+ expect(boxes(2)).toEqual(["2ch", "2ch", "2ch"]);
3303
+ // "-5 %" / "+20 %" / "+5 %" → 5, on every row including the short ones.
3304
+ expect(boxes(3)).toEqual(["5ch", "5ch", "5ch"]);
3305
+ });
3306
+
3307
+ it('sparkline + columns with range "column" share one y scale across rows', () => {
3308
+ const cols: ColumnDef<CityRow>[] = [
3309
+ { accessorKey: "city", header: "City" },
3310
+ {
3311
+ id: "trend",
3312
+ header: "Trend",
3313
+ meta: { visual: { kind: "sparkline", keys: ["q1", "q2", "q3"], range: "column" } },
3314
+ },
3315
+ {
3316
+ id: "bars",
3317
+ header: "Quarters",
3318
+ meta: { visual: { kind: "columns", keys: ["q1", "q2", "q3"], range: "column" } },
3319
+ },
3320
+ ];
3321
+ const { container } = render(<DataTable columns={cols} data={cities.slice(0, 3)} />);
3322
+ const maxes = [...container.querySelectorAll('[data-slot="sparkline-cell-svg"]')].map((svg) =>
3323
+ svg.getAttribute("data-y-max"),
3324
+ );
3325
+ expect(new Set(maxes)).toEqual(new Set(["8"]));
3326
+ const colMaxes = [...container.querySelectorAll('[data-slot="columns-cell-svg"]')].map((svg) =>
3327
+ svg.getAttribute("data-y-max"),
3328
+ );
3329
+ expect(new Set(colMaxes)).toEqual(new Set(["8"]));
3330
+ // Each cell's numbers are in the accessible tree.
3331
+ expect(container.querySelector("tbody tr td:nth-child(2)")).toHaveTextContent(/1.*4.*2/);
3332
+ });
3333
+
3334
+ /**
3335
+ * b-6 — a bar coloured by `region` in a table that prints no region column
3336
+ * carried that category in hue alone: nothing named the groups, and two of
3337
+ * the three categorical tokens are 5/255 apart in greyscale. The key names
3338
+ * them (WCAG 1.4.1), once per source key however many columns use it.
3339
+ */
3340
+ it("a colorBy column draws a named category key, once per source key", () => {
3341
+ const cols: ColumnDef<CityRow>[] = [
3342
+ {
3343
+ accessorKey: "rides",
3344
+ header: "Rides",
3345
+ meta: { visual: { kind: "bar", colorBy: "region", legend: "Region" } },
3346
+ },
3347
+ {
3348
+ accessorKey: "change",
3349
+ header: "Change",
3350
+ meta: { visual: { kind: "bar", colorBy: "region" } },
3351
+ },
3352
+ ];
3353
+ render(<DataTable columns={cols} data={cities} />);
3354
+ const key = screen.getByRole("group", { name: "Region" });
3355
+ expect([...key.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
3356
+ "North",
3357
+ "South",
3358
+ "All",
3359
+ ]);
3360
+ expect(screen.getAllByRole("group", { name: "Region" })).toHaveLength(1);
3361
+ });
3362
+
3363
+ it("legend: false opts a colorBy column out of the key", () => {
3364
+ const cols: ColumnDef<CityRow>[] = [
3365
+ {
3366
+ accessorKey: "rides",
3367
+ header: "Rides",
3368
+ meta: { visual: { kind: "bar", colorBy: "region", legend: false } },
3369
+ },
3370
+ ];
3371
+ const { container } = render(<DataTable columns={cols} data={cities} />);
3372
+ expect(container.querySelector('[data-slot="category-legend"]')).toBeNull();
3373
+ });
3374
+
3375
+ it("meta.format formats the default cell; colorBy washes by category", () => {
3376
+ const cols: ColumnDef<CityRow>[] = [
3377
+ { accessorKey: "city", header: "City", meta: { colorBy: { key: "region", target: "text" } } },
3378
+ {
3379
+ accessorKey: "change",
3380
+ header: "Change",
3381
+ meta: { format: { sign: "always", suffix: " %" } },
3382
+ },
3383
+ ];
3384
+ const { container } = render(<DataTable columns={cols} data={cities.slice(0, 2)} />);
3385
+ expect(container.querySelector("tbody td:nth-child(2)")).toHaveTextContent("-5 %");
3386
+ expect(container.querySelectorAll("tbody td:nth-child(2)")[1]).toHaveTextContent("+20 %");
3387
+ const [oslo, lyon] = [...container.querySelectorAll("tbody td:nth-child(1)")] as HTMLElement[];
3388
+ expect(oslo?.style.color).toMatch(/color-mix/);
3389
+ expect(oslo?.style.color).not.toBe(lyon?.style.color);
3390
+ });
3391
+
3392
+ it("hideHeader keeps the header row for AT but hides it visually", () => {
3393
+ render(<DataTable columns={columns} data={data} hideHeader />);
3394
+ expect(screen.getAllByRole("columnheader")).toHaveLength(2);
3395
+ expect(
3396
+ screen.getByRole("button", { name: "Sort by Name, not sorted" }).closest("span"),
3397
+ ).toHaveClass("sr-only");
3398
+ });
3399
+
3400
+ // RM-127 (a-5): WCAG 2.2 target size (2.5.8). The sort button is 16–20 px of
3401
+ // text and used to clear the rule only through the space AROUND it — which
3402
+ // `hideHeader` (`h-0 p-0`, 16 px between neighbours) and the card sort bar
3403
+ // (`gap-y-1`, 4 px) both take away. Measured in Chromium at 380 px: the
3404
+ // 11 card-bar buttons were 52.4 x 16 and axe reported 6 serious
3405
+ // `target-size` nodes; they are 24 px tall now and axe passes on all 11.
3406
+ it("sizes every sort button to the 24 px target floor, hidden header or card bar", () => {
3407
+ const { unmount } = render(<DataTable columns={columns} data={data} />);
3408
+ expect(screen.getByRole("button", { name: "Sort by Name, not sorted" })).toHaveClass("min-h-6");
3409
+ unmount();
3410
+ render(<DataTable columns={columns} data={data} hideHeader />);
3411
+ expect(screen.getByRole("button", { name: "Sort by Name, not sorted" })).toHaveClass("min-h-6");
3412
+ });
3413
+ });