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