@elabs-ai/components-data 4.0.0 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-data",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,9 @@
24
24
  "registry": "https://registry.npmjs.org/"
25
25
  },
26
26
  "dependencies": {
27
+ "@dnd-kit/core": "6.3.1",
28
+ "@dnd-kit/sortable": "10.0.0",
29
+ "@dnd-kit/utilities": "3.2.2",
27
30
  "@tanstack/react-table": "^8.20.6",
28
31
  "@tanstack/react-virtual": "^3.14.2",
29
32
  "lucide-react": "^0.577.0"
@@ -31,9 +34,9 @@
31
34
  "peerDependencies": {
32
35
  "react": "^18.2.0 || ^19.0.0",
33
36
  "react-dom": "^18.2.0 || ^19.0.0",
34
- "@elabs-ai/components-icons": "4.0.0",
35
- "@elabs-ai/components-ui": "4.0.0",
36
- "@elabs-ai/components-tokens": "4.0.0"
37
+ "@elabs-ai/components-icons": "4.2.0",
38
+ "@elabs-ai/components-tokens": "4.2.0",
39
+ "@elabs-ai/components-ui": "4.2.0"
37
40
  },
38
41
  "devDependencies": {
39
42
  "@testing-library/jest-dom": "^6.6.3",
@@ -49,10 +52,10 @@
49
52
  "typescript": "^5.7.3",
50
53
  "vitest": "^3.0.2",
51
54
  "@elabs-ai/components-eslint-config": "0.1.0",
52
- "@elabs-ai/components-icons": "4.0.0",
53
- "@elabs-ai/components-tokens": "4.0.0",
55
+ "@elabs-ai/components-icons": "4.2.0",
56
+ "@elabs-ai/components-tokens": "4.2.0",
54
57
  "@elabs-ai/components-typescript-config": "0.1.0",
55
- "@elabs-ai/components-ui": "4.0.0"
58
+ "@elabs-ai/components-ui": "4.2.0"
56
59
  },
57
60
  "scripts": {
58
61
  "build": "tsup",
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for FilterChip (`packages/data/src/filter-bar/filter-chip.tsx`), derived from its
4
+ * `Default` story (packages/data/src/filter-bar/filter-chip.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../filter-bar/filter-chip.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `data-filterbar-filterchip--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("FilterChip contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="filter-chip" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="filter-chip"]')).not.toBeNull();
48
+ });
49
+ });
@@ -9,6 +9,7 @@ import {
9
9
  DropdownMenuLabel,
10
10
  DropdownMenuSeparator,
11
11
  DropdownMenuTrigger,
12
+ useLocale,
12
13
  } from "@elabs-ai/components-ui";
13
14
  import { cn } from "@elabs-ai/components-ui/lib/cn";
14
15
 
@@ -19,19 +20,21 @@ export interface ColumnPickerProps<TData> extends ButtonHTMLAttributes<HTMLButto
19
20
  }
20
21
 
21
22
  function ColumnPickerInner<TData>(
22
- { table, label = "Columns", className, ...props }: ColumnPickerProps<TData>,
23
+ { table, label, className, ...props }: ColumnPickerProps<TData>,
23
24
  ref: Ref<HTMLButtonElement>,
24
25
  ) {
26
+ const { t } = useLocale();
27
+ const resolvedLabel = label ?? t("data.columnPicker.label");
25
28
  const columns = table.getAllColumns().filter((c) => c.getCanHide());
26
29
  return (
27
30
  <DropdownMenu>
28
31
  <DropdownMenuTrigger asChild>
29
32
  <Button ref={ref} variant="outline" size="sm" className={cn(className)} {...props}>
30
- {label}
33
+ {resolvedLabel}
31
34
  </Button>
32
35
  </DropdownMenuTrigger>
33
36
  <DropdownMenuContent align="end" className="min-w-[12rem]">
34
- <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
37
+ <DropdownMenuLabel>{t("data.columnPicker.toggleColumns")}</DropdownMenuLabel>
35
38
  <DropdownMenuSeparator />
36
39
  {columns.map((column) => (
37
40
  <DropdownMenuItem
@@ -8,7 +8,7 @@ import type {
8
8
  ColumnFiltersState,
9
9
  VisibilityState,
10
10
  } from "@tanstack/react-table";
11
- import { DataTable } from "./data-table";
11
+ import { DataTable, createSelectionColumn } from "./data-table";
12
12
  import type { DataTableServerArgs, DataTableViewState } from "./data-table";
13
13
  import { FilterBar } from "../filter-bar";
14
14
  import { SearchInput } from "../search-input";
@@ -45,7 +45,10 @@ const columns: ColumnDef<Deployment>[] = [
45
45
  return <Badge variant={statusVariant[s]}>{s}</Badge>;
46
46
  },
47
47
  },
48
- { accessorKey: "latencyMs", header: "Latency (ms)" },
48
+ // #69: `meta.numeric` is the component seam — the header/body/skeleton
49
+ // renderers apply `tabular-nums` + end-alignment for free, instead of a
50
+ // per-story wrapper span.
51
+ { accessorKey: "latencyMs", header: "Latency (ms)", meta: { numeric: true } },
49
52
  ];
50
53
 
51
54
  // `columns` minus the Badge-rendering "Status" cell — for stories below whose
@@ -66,6 +69,8 @@ const meta = {
66
69
  docs: {
67
70
  description: {
68
71
  component:
72
+ "The INTERACTIVE grid; static markup you lay out yourself is `Data/Table` — see " +
73
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
69
74
  "The full data grid (TanStack Table): sorting, filtering, pagination, virtualization, row " +
70
75
  "selection and column management. For a simple static table with no interaction, the lighter " +
71
76
  "Table primitive (see Data/Table, @elabs-ai/components-ui) is enough.",
@@ -93,6 +98,21 @@ export const Lines: Story = {
93
98
  render: () => <DataTable columns={columns} data={rows} zebra={false} />,
94
99
  };
95
100
 
101
+ // ─── Column dividers ────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * `columnDividers` adds a quiet `--rule` hairline between columns, in the header
105
+ * and the body. Off by default; pinned columns keep their own seam instead.
106
+ */
107
+ export const ColumnDividers: Story = {
108
+ render: () => <DataTable columns={columns} data={rows} zebra={false} columnDividers />,
109
+ play: async ({ canvasElement }) => {
110
+ const header = canvasElement.querySelector("thead th");
111
+ await expect(header).not.toBeNull();
112
+ await expect(getComputedStyle(header as Element).borderInlineEndWidth).toBe("1px");
113
+ },
114
+ };
115
+
96
116
  // ─── Sorted ───────────────────────────────────────────────────────────────────
97
117
 
98
118
  export const Sorted: Story = {
@@ -186,6 +206,16 @@ export const WithToolbar: Story = {
186
206
  // matching row should survive. Proves the render-prop toolbar and table share
187
207
  // one filter state.
188
208
  play: async ({ canvas, userEvent }) => {
209
+ // #69 — this IS the exact "data-datatable--with-toolbar" acceptance
210
+ // surface the issue names: the "Latency (ms)" column's `meta.numeric`
211
+ // must reach the REAL rendered header + cell (computed style, not just a
212
+ // class-name assertion) as `text-align: end` + `tabular-nums`.
213
+ const latencyHeader = canvas.getByRole("columnheader", { name: "Latency (ms)" });
214
+ await expect(getComputedStyle(latencyHeader).textAlign).toBe("end");
215
+ const latencyCell = canvas.getByRole("cell", { name: "82" });
216
+ await expect(getComputedStyle(latencyCell).textAlign).toBe("end");
217
+ await expect(getComputedStyle(latencyCell).fontVariantNumeric).toContain("tabular-nums");
218
+
189
219
  await expect(canvas.getByText("api-gateway")).toBeVisible();
190
220
  await userEvent.type(canvas.getByPlaceholderText(/Filter services/), "billing");
191
221
  await waitFor(() => expect(canvas.queryByText("api-gateway")).toBeNull());
@@ -565,6 +595,12 @@ export const ClickableRows: Story = {
565
595
  const rowAction = canvas.getByRole("button", { name: "search" });
566
596
  rowAction.focus();
567
597
  await expect(rowAction).toHaveFocus();
598
+ // #311: the row paints the deliberate compound focus indicator via
599
+ // `has-[[data-slot=data-table-row-action]:focus-visible]:…` — the proxy
600
+ // button itself must suppress its OWN native ring, or it leaks as a
601
+ // stray dot at the row's edge. Real browser (Playwright), so this
602
+ // actually resolves the Tailwind cascade, unlike the jsdom unit test.
603
+ await expect(getComputedStyle(rowAction).outlineStyle).toBe("none");
568
604
  await userEvent.keyboard("{Enter}");
569
605
  await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(2);
570
606
  await expect(clickableRowsOnRowClick.mock.calls[1]![0].original.service).toBe("search");
@@ -802,3 +838,461 @@ export const PinnedColumnsClassicLines: Story = {
802
838
  await expect(getComputedStyle(oddPinned.closest("tr")!).borderBottomWidth).toBe("1px");
803
839
  },
804
840
  };
841
+
842
+ // ─── WithColumnResizing (#12) ───────────────────────────────────────────────────
843
+
844
+ /**
845
+ * `enableColumnResizing` adds a drag handle to the end of every resizable header
846
+ * cell — a WAI-ARIA separator-as-slider, operable by pointer/touch (TanStack's own
847
+ * `getResizeHandler()`) or keyboard (ArrowLeft/ArrowRight while the handle is
848
+ * focused). `columnSizing`/`onColumnSizingChange` make it a controlled slice,
849
+ * exactly like sorting or column pinning; omit them to let the table manage its
850
+ * own widths. A resized column that is ALSO pinned keeps its sticky offset in
851
+ * sync for free — pinning already reads `column.getSize()`.
852
+ */
853
+ export const WithColumnResizing: Story = {
854
+ parameters: {
855
+ docs: {
856
+ description: {
857
+ story:
858
+ "`enableColumnResizing` lets a reader drag — or, with the handle focused, " +
859
+ "arrow-key — a column's edge to change its width.",
860
+ },
861
+ },
862
+ },
863
+ render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
864
+ play: async ({ canvas, userEvent }) => {
865
+ const serviceHeader = canvas.getAllByRole("columnheader")[0]!;
866
+ await expect(serviceHeader.style.width).toBe("150px");
867
+
868
+ const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
869
+
870
+ // #51 — the focus ring moved from the hit box itself onto the `after:`
871
+ // drawn-seam pseudo-element (`focus-visible:after:ring-2`), so the now-24px
872
+ // hit box doesn't draw a 24px focus rectangle over an 8px sliver's worth of
873
+ // approved contrast. Measure the REAL rendered ring via computed style on
874
+ // the pseudo-element — not a class-name string comparison — so a later
875
+ // refactor can't silently drop a visible focus indicator (WCAG 2.4.7)
876
+ // without failing this test.
877
+ await expect(getComputedStyle(handle, "::after").boxShadow).toBe("none");
878
+ handle.focus();
879
+ await expect(getComputedStyle(handle, "::after").boxShadow).not.toBe("none");
880
+
881
+ await expect(handle).toHaveAttribute("aria-valuenow", "150");
882
+
883
+ await userEvent.keyboard("{ArrowRight}");
884
+ await expect(handle).toHaveAttribute("aria-valuenow", "160");
885
+ await expect(serviceHeader.style.width).toBe("160px");
886
+
887
+ await userEvent.keyboard("{ArrowLeft}");
888
+ await expect(handle).toHaveAttribute("aria-valuenow", "150");
889
+ await expect(serviceHeader.style.width).toBe("150px");
890
+ },
891
+ };
892
+
893
+ /**
894
+ * #51 — the resize handle's hit box used to be `w-2` (8px), which is on the
895
+ * Tailwind SPACING scale (`calc(var(--spacing) * 2)`), so it shrank further
896
+ * under `data-density="compact"` (`--spacing` itself is what the density dial
897
+ * rescales — a jsdom class assertion can't see this, only real layout can). The
898
+ * fix reads a literal `w-[min(24px,50%)]`, which this play function measures
899
+ * with `getBoundingClientRect()` UNDER compact density specifically, since
900
+ * that is where the old value was worst (~7.1px) and where a regression would
901
+ * hide from a comfortable-only check.
902
+ */
903
+ export const WithColumnResizingCompactDensity: Story = {
904
+ parameters: {
905
+ docs: {
906
+ description: {
907
+ story:
908
+ "The resize handle's interactive hit box stays ~24px wide even under " +
909
+ '`data-density="compact"`, which shrinks every Tailwind spacing-scale ' +
910
+ "utility (the old `w-2` hit box shrank right along with it).",
911
+ },
912
+ },
913
+ },
914
+ render: () => (
915
+ <div data-density="compact">
916
+ <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />
917
+ </div>
918
+ ),
919
+ play: async ({ canvas }) => {
920
+ const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
921
+ const rect = handle.getBoundingClientRect();
922
+ // A real layout pass must have happened before this means anything.
923
+ await expect(rect.width).toBeGreaterThan(0);
924
+ // `min(24px, 50%)` on a 150px column resolves to a literal 24px — assert a
925
+ // tight tolerance so a regression back toward the old ~7px is caught, but
926
+ // allow for sub-pixel rounding.
927
+ await expect(rect.width).toBeGreaterThan(20);
928
+ await expect(rect.width).toBeLessThanOrEqual(24.5);
929
+ },
930
+ };
931
+
932
+ /**
933
+ * #82 gap 1 — the double-click-reset regression locks in `data-table.test.tsx`
934
+ * use jsdom's `fireEvent.doubleClick`, which dispatches ONLY a synthetic
935
+ * `dblclick` event — never the two real `mousedown`/`mouseup` pairs a browser
936
+ * fires for an actual double click, which `header.getResizeHandler()` (wired
937
+ * to `onMouseDown` on the SAME handle) also listens for. `userEvent.dblClick`
938
+ * DOES synthesize the full mousedown/mouseup ×2 + dblclick sequence, and this
939
+ * play function runs in a real browser (Storybook test runner), so it
940
+ * exercises the actual event path a user's double click takes — and asserts
941
+ * the column lands exactly at its declared size, with no partial resize left
942
+ * behind by whatever `getResizeHandler()`'s pointer-drag state machine did
943
+ * with those two extra mousedown/mouseup pairs.
944
+ */
945
+ export const WithColumnResizingRealDoubleClick: Story = {
946
+ parameters: {
947
+ docs: {
948
+ description: {
949
+ story:
950
+ "A REAL double-click (mousedown/mouseup ×2 + dblclick, not jsdom's " +
951
+ "synthetic dblclick-only event) resets a resized column to its " +
952
+ "declared size, with no partial resize left behind by the drag path.",
953
+ },
954
+ },
955
+ },
956
+ render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
957
+ play: async ({ canvas, userEvent }) => {
958
+ const serviceHeader = canvas.getAllByRole("columnheader")[0]!;
959
+ const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
960
+ await expect(serviceHeader.style.width).toBe("150px");
961
+
962
+ // Move away from the declared size first (keyboard path — already proven
963
+ // real-pointer-equivalent by the plain WithColumnResizing story above),
964
+ // so the double click below has something to reset FROM.
965
+ handle.focus();
966
+ await userEvent.keyboard("{ArrowRight}{ArrowRight}");
967
+ await expect(serviceHeader.style.width).toBe("170px");
968
+
969
+ // The real double click — the actual mousedown/mouseup ×2 + dblclick
970
+ // sequence, not jsdom's dblclick-only synthetic event.
971
+ await userEvent.dblClick(handle);
972
+ await expect(handle).toHaveAttribute("aria-valuenow", "150");
973
+ await expect(serviceHeader.style.width).toBe("150px");
974
+ },
975
+ };
976
+
977
+ /**
978
+ * #82 gap 2 — the resize handle's `min(24px, 50%)` hit box sits over the
979
+ * trailing edge of the SAME header cell a sortable column's own toggle
980
+ * button occupies. jsdom's `fireEvent.click` dispatches straight to a target
981
+ * node with no real hit-testing, so the regression lock in
982
+ * `data-table.test.tsx` can only prove the button RESPONDS to a click
983
+ * addressed to it — not that a real screen click at the button's own
984
+ * on-screen coordinates actually resolves to the button rather than the
985
+ * handle. `document.elementFromPoint` is the browser's real hit-test; this
986
+ * play function samples it right at the sort button's own trailing edge (the
987
+ * side nearest the handle) and asserts it resolves inside the button, then
988
+ * performs the click there and asserts the sort actually toggled.
989
+ *
990
+ * Sampled on TWO columns, not one, because the collision is alignment-
991
+ * dependent: a `start`-aligned header's button sits at the LEADING edge,
992
+ * nowhere near the handle, so it can't exercise the collision at all — round
993
+ * 1 review (independent real-Chromium validation) caught exactly this: the
994
+ * original version of this story sampled only "Service" (start-aligned,
995
+ * button ends 293px clear of the handle) and passed trivially, while
996
+ * "Latency (ms)" — numeric, so `meta.numeric` end-aligns it (#69) — pushes
997
+ * the SAME button's trailing edge 12px UNDER the handle's 24px hit box. The
998
+ * "Service" case is kept because a real regression here (e.g. the handle
999
+ * growing) should still be caught on the unproblematic column too.
1000
+ */
1001
+ export const WithColumnResizingSortToggleHitTest: Story = {
1002
+ parameters: {
1003
+ docs: {
1004
+ description: {
1005
+ story:
1006
+ "The header's sort-toggle button stays hit-testable at its own " +
1007
+ "coordinates when the column is ALSO resizable, i.e. when the " +
1008
+ "24px resize handle is present on the same header cell — for both " +
1009
+ "a start-aligned header and an end-aligned numeric one.",
1010
+ },
1011
+ },
1012
+ },
1013
+ render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
1014
+ play: async ({ canvas, userEvent }) => {
1015
+ async function assertSortButtonHitTestable(
1016
+ accessibleName: string,
1017
+ columnHeaderIndex: number,
1018
+ // TanStack defaults a NUMBER-typed column to descending-first (largest
1019
+ // first reads more useful than smallest-first for e.g. latency) — this
1020
+ // is TanStack's own default heuristic, not something this story
1021
+ // asserts against; the string-typed "Service" column defaults
1022
+ // ascending-first as usual.
1023
+ firstClickSortDirection: "ascending" | "descending",
1024
+ ) {
1025
+ const sortButton = canvas.getByRole("button", { name: accessibleName });
1026
+ const rect = sortButton.getBoundingClientRect();
1027
+ // A real layout pass must have happened before this means anything.
1028
+ await expect(rect.width).toBeGreaterThan(0);
1029
+
1030
+ // The browser's REAL hit-test, sampled 2px inside the button's own
1031
+ // trailing edge — the side nearest the resize handle — so the sample
1032
+ // point can't land outside the button from sub-pixel rounding.
1033
+ const x = rect.right - 2;
1034
+ const y = rect.top + rect.height / 2;
1035
+ const hit = document.elementFromPoint(x, y);
1036
+ await expect(hit).not.toBeNull();
1037
+ await expect(sortButton.contains(hit)).toBe(true);
1038
+
1039
+ const columnHeader = canvas.getAllByRole("columnheader")[columnHeaderIndex]!;
1040
+ await expect(columnHeader).toHaveAttribute("aria-sort", "none");
1041
+ await userEvent.click(hit!);
1042
+ await expect(columnHeader).toHaveAttribute("aria-sort", firstClickSortDirection);
1043
+ }
1044
+
1045
+ // Start-aligned — the button sits far from the handle; kept as the
1046
+ // "nothing regressed on the easy case" control.
1047
+ await assertSortButtonHitTestable("Sort by Service, not sorted", 0, "ascending");
1048
+ // End-aligned numeric — the actual collision surface (#69's `meta.numeric`
1049
+ // pushes this button's trailing edge under the resize handle).
1050
+ await assertSortButtonHitTestable("Sort by Latency (ms), not sorted", 2, "descending");
1051
+ },
1052
+ };
1053
+
1054
+ // ─── Row selection (#11) ───────────────────────────────────────────────────────
1055
+
1056
+ const selectableColumns: ColumnDef<Deployment>[] = [
1057
+ createSelectionColumn<Deployment>(),
1058
+ ...columnsNoBadge,
1059
+ ];
1060
+
1061
+ /**
1062
+ * `createSelectionColumn()` drops a ready-made checkbox column onto any table:
1063
+ * a header select-all (real `indeterminate` for a partial page selection) plus
1064
+ * a per-row checkbox. Uncontrolled here — the table owns `rowSelection`
1065
+ * internally; pass `rowSelection`/`onRowSelectionChange` to drive it from the
1066
+ * app instead.
1067
+ */
1068
+ export const RowSelectionUnselected: Story = {
1069
+ render: () => <DataTable columns={selectableColumns} data={rows} />,
1070
+ play: async ({ canvas }) => {
1071
+ const selectAll = canvas.getAllByRole("checkbox")[0]!;
1072
+ await expect(selectAll).toHaveAttribute("aria-checked", "false");
1073
+ },
1074
+ };
1075
+
1076
+ /** A controlled, partial selection: the header checkbox reads `indeterminate`. */
1077
+ export const RowSelectionPartial: Story = {
1078
+ render: () => (
1079
+ <DataTable
1080
+ columns={selectableColumns}
1081
+ data={rows}
1082
+ rowSelection={{ "0": true, "2": true }}
1083
+ onRowSelectionChange={fn()}
1084
+ />
1085
+ ),
1086
+ play: async ({ canvas }) => {
1087
+ const selectAll = canvas.getAllByRole("checkbox")[0]!;
1088
+ await expect(selectAll).toHaveAttribute("aria-checked", "mixed");
1089
+ },
1090
+ };
1091
+
1092
+ /** A controlled, full-page selection: the header checkbox reads `checked`. */
1093
+ export const RowSelectionAllSelected: Story = {
1094
+ render: () => (
1095
+ <DataTable
1096
+ columns={selectableColumns}
1097
+ data={rows}
1098
+ rowSelection={Object.fromEntries(rows.map((_, i) => [String(i), true]))}
1099
+ onRowSelectionChange={fn()}
1100
+ />
1101
+ ),
1102
+ play: async ({ canvas }) => {
1103
+ const selectAll = canvas.getAllByRole("checkbox")[0]!;
1104
+ await expect(selectAll).toHaveAttribute("aria-checked", "true");
1105
+ },
1106
+ };
1107
+
1108
+ /**
1109
+ * A bulk-action toolbar is not a separate prop — `toolbar={(table) => …}`
1110
+ * already hands the live table instance to the render-prop, so a consumer
1111
+ * builds "N selected · Clear" from `table.getSelectedRowModel()` the same way
1112
+ * `WithToolbar` builds a filter bar from the same instance.
1113
+ */
1114
+ export const RowSelectionWithToolbar: Story = {
1115
+ render: () => (
1116
+ <DataTable
1117
+ columns={selectableColumns}
1118
+ data={rows}
1119
+ toolbar={(table) => {
1120
+ const selectedCount = table.getSelectedRowModel().rows.length;
1121
+ if (selectedCount === 0) return null;
1122
+ return (
1123
+ <FilterBar
1124
+ actions={
1125
+ <Button
1126
+ type="button"
1127
+ size="sm"
1128
+ variant="outline"
1129
+ onClick={() => table.resetRowSelection()}
1130
+ >
1131
+ Clear selection
1132
+ </Button>
1133
+ }
1134
+ >
1135
+ <span className="text-body">{selectedCount} selected</span>
1136
+ </FilterBar>
1137
+ );
1138
+ }}
1139
+ />
1140
+ ),
1141
+ play: async ({ canvas, userEvent }) => {
1142
+ await expect(canvas.queryByText(/selected$/)).toBeNull();
1143
+ const rowCheckboxes = canvas.getAllByRole("checkbox").slice(1);
1144
+ await userEvent.click(rowCheckboxes[0]!);
1145
+ await userEvent.click(rowCheckboxes[1]!);
1146
+ await expect(canvas.getByText("2 selected")).toBeVisible();
1147
+ await userEvent.click(canvas.getByText("Clear selection"));
1148
+ await expect(canvas.queryByText(/selected$/)).toBeNull();
1149
+ },
1150
+ };
1151
+
1152
+ /**
1153
+ * Dark-theme pass for the same interaction + a11y coverage (#11 fix round —
1154
+ * the light-only Storybook run isn't sufficient for the "both shipping
1155
+ * themes" quality gate). Reuses `RowSelectionWithToolbar`'s render/play under
1156
+ * `globals: { theme: "dark" }`, same pattern as `dialog.stories.tsx`'s
1157
+ * `FocusRingClearanceDark`.
1158
+ */
1159
+ export const RowSelectionWithToolbarDark: Story = {
1160
+ name: "Row selection — with toolbar (dark)",
1161
+ globals: { theme: "dark" },
1162
+ render: RowSelectionWithToolbar.render,
1163
+ play: async (context) => {
1164
+ await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("dark"));
1165
+ await RowSelectionWithToolbar.play!(context);
1166
+ },
1167
+ };
1168
+
1169
+ // ─── RowReorder (#13) ───────────────────────────────────────────────────────
1170
+
1171
+ /**
1172
+ * Opt-in row drag-reorder. Fully controlled, like every other DataTable slice:
1173
+ * `onRowReorder` reports the move as `(from, to, row)` and this story
1174
+ * re-orders its own `data` in response — the component never mutates `data`
1175
+ * itself.
1176
+ *
1177
+ * Try both ways to move a row: drag the grip handle with a mouse, or Tab to
1178
+ * it and use the keyboard — Space/Enter picks a row up, Arrow Up/Down moves
1179
+ * it, Space/Enter drops it, Escape cancels. Every position change is
1180
+ * announced through a live region (WCAG 4.1.3).
1181
+ */
1182
+ export const RowReorder: Story = {
1183
+ render: () => {
1184
+ // eslint-disable-next-line react-hooks/rules-of-hooks
1185
+ const [items, setItems] = useState(rows);
1186
+ return (
1187
+ <DataTable
1188
+ columns={columnsNoBadge}
1189
+ data={items}
1190
+ enableRowReorder
1191
+ onRowReorder={(from, to) => {
1192
+ setItems((current) => {
1193
+ const next = current.slice();
1194
+ const [moved] = next.splice(from, 1);
1195
+ next.splice(to, 0, moved!);
1196
+ return next;
1197
+ });
1198
+ }}
1199
+ />
1200
+ );
1201
+ },
1202
+ play: async ({ canvas, userEvent }) => {
1203
+ // Keyboard flow — the mandatory path (issue #13 carries the
1204
+ // `accessibility` label): pick up the first row ("api-gateway"), move it
1205
+ // down one position, and drop.
1206
+ const handle = canvas.getByRole("button", { name: "Reorder api-gateway" });
1207
+ handle.focus();
1208
+ await expect(handle).toHaveFocus();
1209
+
1210
+ await userEvent.keyboard(" ");
1211
+ await expect(handle).toHaveAttribute("aria-pressed", "true");
1212
+
1213
+ // `KeyboardSensor.attach()` (`@dnd-kit/core`) defers attaching the
1214
+ // listener for the subsequent move/drop keys by one real macrotask
1215
+ // (`setTimeout(fn, 0)`) — the same tick the unit tests in
1216
+ // `data-table.test.tsx` wait out.
1217
+ await new Promise((resolve) => setTimeout(resolve, 0));
1218
+
1219
+ await userEvent.keyboard("{ArrowDown}");
1220
+ await new Promise((resolve) => setTimeout(resolve, 0));
1221
+
1222
+ await userEvent.keyboard(" ");
1223
+
1224
+ await waitFor(() => {
1225
+ const gripButtons = canvas.getAllByRole("button", { name: /^Reorder /i });
1226
+ expect(gripButtons[0]).toHaveAccessibleName("Reorder billing");
1227
+ expect(gripButtons[1]).toHaveAccessibleName("Reorder api-gateway");
1228
+ });
1229
+ },
1230
+ };
1231
+
1232
+ /**
1233
+ * `rowReorderHandle="row"` makes the whole row the drag activator — no extra
1234
+ * grip column. Reach for this only when the row has no other primary
1235
+ * interaction (no `onRowClick`), since the row itself and a click target
1236
+ * would otherwise compete for the same surface.
1237
+ */
1238
+ export const RowReorderWholeRow: Story = {
1239
+ render: () => {
1240
+ // eslint-disable-next-line react-hooks/rules-of-hooks
1241
+ const [items, setItems] = useState(rows);
1242
+ return (
1243
+ <DataTable
1244
+ columns={columnsNoBadge}
1245
+ data={items}
1246
+ enableRowReorder
1247
+ rowReorderHandle="row"
1248
+ onRowReorder={(from, to) => {
1249
+ setItems((current) => {
1250
+ const next = current.slice();
1251
+ const [moved] = next.splice(from, 1);
1252
+ next.splice(to, 0, moved!);
1253
+ return next;
1254
+ });
1255
+ }}
1256
+ />
1257
+ );
1258
+ },
1259
+ play: async ({ canvas, userEvent }) => {
1260
+ // Pointer drag — the other half of acceptance criterion "Pointer drag
1261
+ // works (via @dnd-kit/sortable sensor)". This can only be verified in a
1262
+ // REAL browser: `data-table.test.tsx` documents that jsdom has no
1263
+ // `PointerEvent` constructor at all, so `PointerSensor`'s own activator
1264
+ // gate rejects every synthetic pointerdown there regardless of test
1265
+ // code. Storybook's interaction-test runner drives real Chromium, which
1266
+ // has a genuine `PointerEvent`.
1267
+ // Drags the whole first row ("api-gateway") past the second row
1268
+ // ("billing") and drops.
1269
+ const firstRow = canvas.getByText("api-gateway").closest("tr") as HTMLElement;
1270
+ const secondRow = canvas.getByText("billing").closest("tr") as HTMLElement;
1271
+ const from = firstRow.getBoundingClientRect();
1272
+ const to = secondRow.getBoundingClientRect();
1273
+ const startX = from.left + 20;
1274
+ const startY = from.top + from.height / 2;
1275
+
1276
+ // `reorderSensors`'s `PointerSensor` uses `activationConstraint: { distance: 4 }`
1277
+ // (data-table.tsx): dnd-kit's own `handleMove` treats the FIRST move past that
1278
+ // threshold as arming the drag only — it calls `handleStart()` (recording the
1279
+ // *initial* pointer-down position) and returns WITHOUT ever calling `onMove`, so
1280
+ // that event never updates the tracked drag position. A single big pointer jump
1281
+ // straight from the row to the drop target therefore "activates" the drag but
1282
+ // reports no movement at all, and the row lands back where it started. A SECOND
1283
+ // move — after activation — is required to actually report the target position,
1284
+ // so the sequence below moves twice: once just past the 4px threshold, then once
1285
+ // more to the real drop point.
1286
+ await userEvent.pointer([
1287
+ { keys: "[MouseLeft>]", target: firstRow, coords: { clientX: startX, clientY: startY } },
1288
+ { coords: { clientX: startX, clientY: startY + 10 } },
1289
+ { coords: { clientX: to.left + 20, clientY: to.bottom - 4 } },
1290
+ { keys: "[/MouseLeft]" },
1291
+ ]);
1292
+
1293
+ await waitFor(() => {
1294
+ const cells = canvas.getAllByRole("cell");
1295
+ expect(cells[0]).toHaveTextContent("billing");
1296
+ });
1297
+ },
1298
+ };