@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.
@@ -1,6 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
4
12
  import {
5
13
  flexRender,
6
14
  getCoreRowModel,
@@ -12,18 +20,127 @@ import {
12
20
  type ColumnDef,
13
21
  type ColumnFiltersState,
14
22
  type ColumnPinningState,
23
+ type ColumnSizingState,
15
24
  type OnChangeFn,
16
25
  type PaginationState,
17
26
  type Row,
27
+ type RowData,
28
+ type RowSelectionState,
18
29
  type SortingState,
19
30
  type Table as TanstackTable,
20
31
  type VisibilityState,
21
32
  } from "@tanstack/react-table";
22
33
  import { useVirtualizer } from "@tanstack/react-virtual";
23
- import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
24
- import { Button, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
34
+ // Row drag-reorder (#13). @dnd-kit is the only DnD primitive in the repo (reuse
35
+ // audit found none) — MIT-licensed, attributed in scripts/attributions.sources.json.
36
+ // KeyboardSensor + sortableKeyboardCoordinates already implement the exact key
37
+ // model the issue asks for (Space/Enter lift, arrows move, Space/Enter drop,
38
+ // Escape cancel) and DndContext's built-in `Accessibility` component renders the
39
+ // aria-live announcer — this file supplies the localized announcement text, the
40
+ // localized screen-reader instructions + role description (#98 — dnd-kit ships
41
+ // its own hardcoded-English defaults for both, which need an explicit override
42
+ // same as everything else this feature says out loud), and the token-driven
43
+ // visuals.
44
+ import {
45
+ DndContext,
46
+ KeyboardSensor,
47
+ PointerSensor,
48
+ closestCenter,
49
+ useSensor,
50
+ useSensors,
51
+ type Announcements,
52
+ type DragCancelEvent,
53
+ type DragEndEvent,
54
+ type DragOverEvent,
55
+ type DragStartEvent,
56
+ type DraggableAttributes,
57
+ type DraggableSyntheticListeners,
58
+ } from "@dnd-kit/core";
59
+ import {
60
+ SortableContext,
61
+ sortableKeyboardCoordinates,
62
+ useSortable,
63
+ verticalListSortingStrategy,
64
+ } from "@dnd-kit/sortable";
65
+ import { CSS } from "@dnd-kit/utilities";
66
+ import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from "lucide-react";
67
+ import { Button, Checkbox, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
25
68
  import { cn } from "@elabs-ai/components-ui/lib/cn";
26
69
 
70
+ // ─── Column meta seam (#69) ─────────────────────────────────────────────────────
71
+ // `columnDef.meta` is where TanStack lets a caller attach column-specific,
72
+ // renderer-agnostic data — `DataTable` reads exactly two keys from it so
73
+ // numeric-column styling (interaction-guidelines.md § Micro-typography:
74
+ // "tabular-nums for any number column … DataTable numeric cells") is the
75
+ // component's job, not a per-caller convention rediscovered at every call
76
+ // site. Exported (not just declared) so a consumer's own `ColumnDef` literal
77
+ // type-checks against a NAMED type, per component-api.md § Types.
78
+
79
+ /**
80
+ * `DataTable`'s `columnDef.meta` contract, read by the header/body/skeleton
81
+ * cell renderers. Set `numeric: true` on a column to get `tabular-nums` +
82
+ * end-alignment on both the `<th>` and every `<td>` (including the loading
83
+ * skeleton) for free.
84
+ */
85
+ export interface DataTableColumnMeta {
86
+ /** Numeric column: tabular figures + end alignment on header and cells. */
87
+ numeric?: boolean;
88
+ /**
89
+ * Explicit alignment override for when `numeric` isn't the right cue (or
90
+ * to align a non-numeric column). Independent of `numeric` — `numeric`
91
+ * alone still drives `tabular-nums` even when `align` overrides the
92
+ * alignment away from `"end"`.
93
+ */
94
+ align?: "start" | "center" | "end";
95
+ }
96
+
97
+ declare module "@tanstack/react-table" {
98
+ // `TData`/`TValue` must stay in the signature to match the interface being
99
+ // augmented, even though `DataTableColumnMeta` (deliberately) doesn't use
100
+ // them; the empty extends-body is how TanStack's own module-augmentation
101
+ // pattern for `ColumnMeta` is documented.
102
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type
103
+ interface ColumnMeta<TData extends RowData, TValue> extends DataTableColumnMeta {}
104
+ }
105
+
106
+ /**
107
+ * `<th>`/`<td>`/skeleton-`<td>` className for a column's `meta.numeric`/`meta.align`
108
+ * (#69). A pure, module-level helper (no component state) so all three call
109
+ * sites — header, body cell, loading skeleton — stay in lockstep; a drift
110
+ * between them is exactly the "skeleton doesn't mirror the real layout" bug
111
+ * loading-states.md warns about. `meta` is typed as the exported
112
+ * `DataTableColumnMeta` (structurally satisfied by TanStack's augmented
113
+ * `ColumnMeta<TData, TValue>`) so the helper doesn't need the table's generic
114
+ * row type.
115
+ *
116
+ * Deliberately takes NO options and NO padding branch: round-1 (#82
117
+ * follow-up) briefly reserved an extra 36px of trailing `<th>` padding here
118
+ * to clear the resize handle, but that moved the header's alignment
119
+ * reference point 24px away from the body `<td>`'s (which keeps the plain
120
+ * 12px `px-3`) — an end-aligned numeric column's own header no longer lined
121
+ * up with the values it labels, defeating the whole point of #69. Reserving
122
+ * space via padding necessarily desyncs header from body, because only the
123
+ * header has a handle to clear. The round-2 fix instead resolves the
124
+ * hit-test collision at the CONTROL that needs to win it — see the sort
125
+ * button's `relative z-10` below — so header and body padding stay
126
+ * byte-identical and this helper only ever contributes alignment +
127
+ * tabular-nums classes.
128
+ */
129
+ function numericColumnClasses(meta: DataTableColumnMeta | undefined) {
130
+ if (!meta?.numeric && !meta?.align) return undefined;
131
+ const alignClass =
132
+ meta?.align === "start"
133
+ ? "text-start"
134
+ : meta?.align === "center"
135
+ ? "text-center"
136
+ : meta?.align === "end"
137
+ ? "text-end"
138
+ : meta?.numeric
139
+ ? "text-end"
140
+ : undefined;
141
+ return cn(alignClass, meta?.numeric && "tabular-nums");
142
+ }
143
+
27
144
  // ─── Public types ─────────────────────────────────────────────────────────────
28
145
 
29
146
  /** Snapshot of table slice state — used for saved-view serialise/rehydrate. */
@@ -39,6 +156,17 @@ export interface DataTableViewState {
39
156
  * that already constructs a `DataTableViewState` literal.
40
157
  */
41
158
  columnPinning?: ColumnPinningState;
159
+ /**
160
+ * Which rows are checked (#11), keyed by row id — see `getRowId`. OPTIONAL
161
+ * like `columnPinning`, for the same reason: the other members predate it.
162
+ */
163
+ rowSelection?: RowSelectionState;
164
+ /**
165
+ * Per-column widths after resizing (#12), keyed by column id. OPTIONAL like
166
+ * `columnPinning`/`rowSelection`, for the same reason: the other members
167
+ * predate it.
168
+ */
169
+ columnSizing?: ColumnSizingState;
42
170
  }
43
171
 
44
172
  /**
@@ -135,6 +263,83 @@ export interface DataTableProps<TData, TValue> extends Omit<
135
263
  columnPinning?: ColumnPinningState;
136
264
  onColumnPinningChange?: OnChangeFn<ColumnPinningState>;
137
265
 
266
+ /**
267
+ * Opt in to column resizing (#12): a drag handle renders on every
268
+ * resizable column's trailing edge — pointer-draggable (TanStack's own
269
+ * `header.getResizeHandler()`) and keyboard-operable (ArrowLeft/ArrowRight
270
+ * on the focused handle, per the WAI-ARIA separator-as-slider practice).
271
+ * Default `false` so a table that doesn't opt in renders byte-identical
272
+ * markup to before this feature existed — no handle, no per-cell width
273
+ * styling.
274
+ */
275
+ enableColumnResizing?: boolean;
276
+ /**
277
+ * When `columnSizing` updates: `"onChange"` (default here — TanStack's own
278
+ * default is `"onEnd"`) live-updates while dragging; `"onEnd"` updates once
279
+ * on release. Only meaningful when `enableColumnResizing` is set.
280
+ */
281
+ columnResizeMode?: "onChange" | "onEnd";
282
+ /**
283
+ * Controlled column-widths state (#12), keyed by column id — the SAME
284
+ * controlled/uncontrolled shape as `columnPinning`/`rowSelection`.
285
+ * Uncontrolled sizing can be seeded once via `initialView.columnSizing`.
286
+ *
287
+ * A pinned column's sticky offset (`getStart("left")`/`getAfter("right")`)
288
+ * already sums `column.getSize()`, which folds in a `columnSizing`
289
+ * override automatically — so pinning and resizing compose with no extra
290
+ * wiring once this state reaches the table.
291
+ *
292
+ * Sizing is a LAYOUT concern, like `columnPinning`/`rowSelection` — it is
293
+ * client-only and never joins `DataTableServerArgs` / `onServerChange`.
294
+ */
295
+ columnSizing?: ColumnSizingState;
296
+ onColumnSizingChange?: OnChangeFn<ColumnSizingState>;
297
+
298
+ /**
299
+ * Controlled row-selection state (#11) — which rows are checked, keyed by
300
+ * row id (see `getRowId`). When provided the component is
301
+ * selection-controlled; otherwise it manages the slice internally and can
302
+ * be seeded once via `initialView.rowSelection`. Pair it with a selection
303
+ * column built by `createSelectionColumn` (or drive it yourself off the
304
+ * `table` instance handed to `toolbar`).
305
+ *
306
+ * Selection is a LAYOUT/UI concern, not a query concern — like
307
+ * `columnPinning`, it is client-only and never joins `DataTableServerArgs` /
308
+ * `onServerChange`.
309
+ */
310
+ rowSelection?: RowSelectionState;
311
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>;
312
+ /**
313
+ * Which rows can be selected: `true`/`false` for all rows, or a predicate
314
+ * evaluated per row. Passed straight through to `useReactTable`. Default
315
+ * (TanStack's own): `true`.
316
+ */
317
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
318
+ /**
319
+ * Allow more than one row to be selected at once. Default (TanStack's own):
320
+ * `true`. Set `false` for single-select (radio-style) behaviour.
321
+ */
322
+ enableMultiRowSelection?: boolean;
323
+ /**
324
+ * Stable row id, independent of row INDEX. TanStack's default id is set
325
+ * ONCE per row object when the core row model is built, then reused by
326
+ * reference through sorting/filtering — so a client-side sort or filter
327
+ * does NOT disturb selection identity even without this prop. The real
328
+ * hazard is a `data` array replacement: when the app passes NEW object
329
+ * references (a re-fetch, an optimistic update), TanStack rebuilds the
330
+ * core row model from scratch and reassigns default (index-based) ids, so a
331
+ * row that kept its position but got a new object still keeps its
332
+ * selection — but one that MOVED position silently inherits whatever
333
+ * selection belonged to the id now sitting at its old index. This is
334
+ * unavoidable under `manualPagination`: each page IS a fresh `data` array,
335
+ * so the default index-based id restarts at `0` on every page and a
336
+ * selection made on one page can collide with a different record on the
337
+ * next. Supply `getRowId` whenever `data` can be replaced with new object
338
+ * references (including every server-paginated table) so identity survives
339
+ * the replacement instead of falling back to index.
340
+ */
341
+ getRowId?: (row: TData, index: number) => string;
342
+
138
343
  /**
139
344
  * One-shot rehydrate for uncontrolled slices only (ignored for any slice
140
345
  * whose corresponding controlled prop is set). Maps to `useReactTable`'s
@@ -210,6 +415,54 @@ export interface DataTableProps<TData, TValue> extends Omit<
210
415
  */
211
416
  zebra?: boolean;
212
417
 
418
+ /**
419
+ * Draw a quiet `--rule` hairline between columns (header and body). Off by
420
+ * default. Pinned cells keep their own seam and never take a divider.
421
+ */
422
+ columnDividers?: boolean;
423
+
424
+ // ── Row drag-reorder (#13) ───────────────────────────────────────────────
425
+ /**
426
+ * Opt-in row drag-reorder. Off by default — an existing table renders
427
+ * byte-identical markup with no extra DOM per row until this is set.
428
+ * Fully controlled like every other slice: the component never mutates
429
+ * `data` itself, it only reports the move via `onRowReorder`; the caller
430
+ * re-orders `data` in response.
431
+ *
432
+ * Keyboard-operable out of the box (`@dnd-kit`'s default keyboard sensor):
433
+ * Space/Enter picks a row up, Arrow Up/Down moves it, Space/Enter drops it,
434
+ * Escape cancels. Every position change is announced through a live region
435
+ * (WCAG 4.1.3).
436
+ *
437
+ * Mutually exclusive with `enableRowVirtualization` — a windowed table
438
+ * can't keep dnd-kit's sortable list and a virtualizer in sync, so reorder
439
+ * is silently disabled (a dev warning fires) when both are set. Combining
440
+ * it with active `sorting` also fires a dev warning (both still work, but
441
+ * a sort re-orders the very rows a drag just moved, which reads as broken).
442
+ */
443
+ enableRowReorder?: boolean;
444
+ /**
445
+ * Fires when a row is dropped in a new position. `from`/`to` are indices
446
+ * into the **`data` array you passed in** — never into the sorted, filtered
447
+ * or paginated view the table renders — so they are safe to use directly
448
+ * with `arrayMove`/`slice`+`splice`/immer against your own `data`, unchanged
449
+ * by an active sort or by client-side pagination (the dragged row's true
450
+ * index in the full array, not its index on the current page). Under
451
+ * `manualPagination`, `data` IS the current page, so `from`/`to` are
452
+ * page-relative — reorder that page's own array with them. `row` is the
453
+ * moved record (`data[from]`).
454
+ */
455
+ onRowReorder?: (from: number, to: number, row: TData) => void;
456
+ /**
457
+ * Where the drag activator lives. `"cell"` (default) renders a dedicated
458
+ * grip-handle column so the rest of the row keeps its ordinary click/
459
+ * keyboard behavior untouched. `"row"` makes the whole row itself the drag
460
+ * activator (no extra column) — reach for this only when the row has no
461
+ * other primary interaction (e.g. no `onRowClick`), since a whole-row
462
+ * activator and a row click target the same surface.
463
+ */
464
+ rowReorderHandle?: "cell" | "row";
465
+
213
466
  /**
214
467
  * Fires when a row is activated (#337). Setting it adds ONE activation
215
468
  * target per row: a visually-hidden `<button>` rendered inside the row's
@@ -291,6 +544,14 @@ function isActiveTextSelection(): boolean {
291
544
  const PINNED_SEAM_CLASS =
292
545
  "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
293
546
 
547
+ /**
548
+ * Opt-in `columnDividers` hairline. `--rule`, not `--border-strong`: the column
549
+ * is already told apart by alignment and whitespace, so this line is a
550
+ * redundant boundary (ADR 0010). A real border is fine here, unlike the pinned
551
+ * seam above — pinned cells never take it.
552
+ */
553
+ const COLUMN_DIVIDER_CLASS = "border-e border-rule last:border-e-0";
554
+
294
555
  /**
295
556
  * Ids of leaf columns whose ORIGINAL `ColumnDef` declares no `size` (#333).
296
557
  *
@@ -328,6 +589,194 @@ function unsizedColumnIds<TData, TValue>(defs: readonly ColumnDef<TData, TValue>
328
589
  return out;
329
590
  }
330
591
 
592
+ // ─── Column resizing (#12) ────────────────────────────────────────────────────
593
+
594
+ /**
595
+ * Explicit width/min/max triad for one column at its CURRENT size.
596
+ *
597
+ * The table is auto-layout (see the note on `pinnedCellGeometry` below), so
598
+ * without an explicit width an unpinned column is pure browser auto-layout —
599
+ * `column.getSize()` can change (via a drag or a keyboard resize) with
600
+ * nothing rendering differently. A pinned cell already gets this triad from
601
+ * `pinnedCellGeometry`'s own `style`; this is the same triad for the
602
+ * UNPINNED case, so every call site can compute it once and use it in both
603
+ * the pinned-or-not branches (`geometry?.style ?? resizeWidthStyle(size)`).
604
+ * Every call site gates this behind `enableColumnResizing`, so a table that
605
+ * doesn't opt in renders byte-identical markup to before this feature
606
+ * existed.
607
+ */
608
+ function resizeWidthStyle(size: number): React.CSSProperties {
609
+ return { width: size, minWidth: size, maxWidth: size };
610
+ }
611
+
612
+ // ─── Row-selection column (#11) ──────────────────────────────────────────────
613
+ //
614
+ // `flexRender` mounts a function `header`/`cell` as a real React component
615
+ // (`React.createElement(Comp, props)`, not a bare function call — see
616
+ // `@tanstack/react-table`'s `flexRender`), so these are ordinary components:
617
+ // hooks (`useLocale`) are safe inside them.
618
+
619
+ /**
620
+ * The row's own "primary identifier" — the first visible DATA column's value,
621
+ * skipping display columns that carry no `accessorKey`/`accessorFn` (e.g. a
622
+ * leading `createSelectionColumn()` checkbox, or a decorative avatar column).
623
+ * `column.accessorFn` is public TanStack API, populated for any
624
+ * `accessorKey`/`accessorFn` column and `undefined` for a pure display column
625
+ * (`core/column.ts`) — so this is a reliable "is this a data column" test.
626
+ * Shared by `rowActionName` (#337) and the selection column's per-row
627
+ * accessible name (#11 I4/I6), so a leading selection column can't silently
628
+ * degrade either one to its generic fallback.
629
+ */
630
+ function firstDataCellValue<TData>(row: Row<TData>): string | undefined {
631
+ for (const cell of row.getVisibleCells()) {
632
+ if (!cell.column.accessorFn) continue;
633
+ const value = cell.getValue();
634
+ if (typeof value === "string" && value.trim() !== "") return value;
635
+ if (typeof value === "number") return String(value);
636
+ }
637
+ return undefined;
638
+ }
639
+
640
+ /**
641
+ * Select-all header cell. Radix `Checkbox` renders a genuinely distinct
642
+ * `indeterminate` glyph + `aria-checked="mixed"` for a partial page
643
+ * selection (see `checkbox.tsx`), so the visual and the accessible state
644
+ * agree without any extra wiring here.
645
+ */
646
+ function SelectAllHeaderCell<TData>({ table }: { table: TanstackTable<TData> }) {
647
+ const { t } = useLocale();
648
+ const allSelected = table.getIsAllPageRowsSelected();
649
+ const someSelected = table.getIsSomePageRowsSelected();
650
+ return (
651
+ <Checkbox
652
+ data-slot="data-table-select-all"
653
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
654
+ onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
655
+ aria-label={t("data.table.selectAllRows")}
656
+ />
657
+ );
658
+ }
659
+
660
+ /**
661
+ * Per-row checkbox cell — disabled when `enableRowSelection` excludes the
662
+ * row. Names each checkbox from the row's own data (#11 I4) instead of the
663
+ * identical generic label every row previously shared, using the same
664
+ * "first data cell" lookup `rowActionName` (#337) already uses.
665
+ */
666
+ function SelectRowCell<TData>({ row }: { row: Row<TData> }) {
667
+ const { t } = useLocale();
668
+ const name = firstDataCellValue(row);
669
+ return (
670
+ <Checkbox
671
+ data-slot="data-table-select-cell"
672
+ checked={row.getIsSelected()}
673
+ disabled={!row.getCanSelect()}
674
+ onCheckedChange={(checked) => row.toggleSelected(checked === true)}
675
+ aria-label={name ? t("data.table.selectRowNamed", { name }) : t("data.table.selectRow")}
676
+ />
677
+ );
678
+ }
679
+
680
+ /**
681
+ * Ready-made checkbox selection column (#11): header select-all (with a real
682
+ * `indeterminate` state for a partial page selection) + a per-row checkbox,
683
+ * both built on `@elabs-ai/components-ui`'s `Checkbox` — never hand-roll one.
684
+ *
685
+ * Add it to `columns` and pair it with `rowSelection` / `onRowSelectionChange`
686
+ * (or leave both uncontrolled and read `table.getSelectedRowModel()` from a
687
+ * `toolbar` render-prop to build a bulk-action bar).
688
+ *
689
+ * Declares an explicit `size` (40px) so it plays nicely if a caller pins it —
690
+ * every pinned column must declare one (#333) — without the dev warning.
691
+ */
692
+ export function createSelectionColumn<TData>(): ColumnDef<TData> {
693
+ return {
694
+ id: "select",
695
+ size: 40,
696
+ enableSorting: false,
697
+ enableHiding: false,
698
+ header: ({ table }) =>
699
+ // #11 C1: `toggleAllPageRowsSelected` wipes-then-sets on every row when
700
+ // `enableMultiRowSelection` is off (TanStack's `mutateRowIsSelected`), so
701
+ // a select-all header under single-select leaves only the LAST row
702
+ // selected and pins the header at indeterminate forever. Suppress it.
703
+ table.options.enableMultiRowSelection === false ? null : (
704
+ <SelectAllHeaderCell table={table} />
705
+ ),
706
+ cell: ({ row }) => <SelectRowCell row={row} />,
707
+ };
708
+ }
709
+
710
+ // ─── Row drag-reorder (#13) ─────────────────────────────────────────────────
711
+
712
+ /** Render-prop payload `SortableDataRow` hands its child — the live dnd-kit
713
+ * registration for one row. */
714
+ interface SortableRowRenderArgs {
715
+ setNodeRef: (node: HTMLElement | null) => void;
716
+ setActivatorNodeRef: (node: HTMLElement | null) => void;
717
+ attributes: DraggableAttributes;
718
+ listeners: DraggableSyntheticListeners;
719
+ isDragging: boolean;
720
+ style: React.CSSProperties;
721
+ }
722
+
723
+ /**
724
+ * Per-row `@dnd-kit` registration, defined ONCE at module level.
725
+ *
726
+ * This must be a real component, not a hook call inlined into `rows.map()`
727
+ * (that would call `useSortable` a variable number of times across renders —
728
+ * the classic "hook in a loop" Rules-of-Hooks violation the moment the row
729
+ * count changes) and not a component DEFINED inside `DataTableInner`'s body
730
+ * either (a function created fresh every render gets a new `type` identity,
731
+ * so React would tear down and remount the whole row subtree, including
732
+ * dnd-kit's own internal drag state, on every re-render). A stable top-level
733
+ * component keyed by `id` gives every row its own persistent `useSortable`
734
+ * state via ordinary type+key reconciliation.
735
+ *
736
+ * `transition: null` is deliberate — dnd-kit's own transition is a raw
737
+ * inline `ms` duration, which would bypass the gated `duration-*`/`ease-*`
738
+ * utilities (quality-gates.md "Motion-tokened"). The moving row instead gets
739
+ * `transition-transform duration-base ease-standard motion-reduce:transition-none`
740
+ * as a class at the call site; only the live `transform` stays inline.
741
+ */
742
+ function SortableDataRow({
743
+ id,
744
+ disabled,
745
+ attributesOverride,
746
+ children,
747
+ }: {
748
+ id: string;
749
+ disabled?: boolean;
750
+ /**
751
+ * `rowReorderHandle: "row"` applies `attributes`/`listeners` straight to
752
+ * the `<tr>` (no separate activator element), so dnd-kit's DEFAULT
753
+ * `role="button"` would replace the table's own `role="row"` on that
754
+ * element — destroying its row semantics. Override the role in that mode
755
+ * only; `"cell"` mode leaves `role` unset because the grip `<button>` —
756
+ * not the `<tr>` — receives `attributes`/`listeners`. `roleDescription` is
757
+ * overridden in BOTH modes (#98) — it carries dnd-kit's localized
758
+ * `aria-roledescription`, which the activator needs regardless of which
759
+ * element is the activator.
760
+ */
761
+ attributesOverride?: { role?: string; roleDescription?: string; tabIndex?: number };
762
+ children: (args: SortableRowRenderArgs) => ReactNode;
763
+ }) {
764
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging } =
765
+ useSortable({ id, disabled, transition: null, attributes: attributesOverride });
766
+ return (
767
+ <>
768
+ {children({
769
+ setNodeRef,
770
+ setActivatorNodeRef,
771
+ attributes,
772
+ listeners,
773
+ isDragging,
774
+ style: { transform: CSS.Transform.toString(transform) },
775
+ })}
776
+ </>
777
+ );
778
+ }
779
+
331
780
  // ─── Component (inner, generic) ───────────────────────────────────────────────
332
781
 
333
782
  /**
@@ -368,6 +817,15 @@ function DataTableInner<TData, TValue>(
368
817
  onPaginationChange: onPaginationChangeProp,
369
818
  columnPinning: columnPinningProp,
370
819
  onColumnPinningChange: onColumnPinningChangeProp,
820
+ enableColumnResizing = false,
821
+ columnResizeMode = "onChange",
822
+ columnSizing: columnSizingProp,
823
+ onColumnSizingChange: onColumnSizingChangeProp,
824
+ rowSelection: rowSelectionProp,
825
+ onRowSelectionChange: onRowSelectionChangeProp,
826
+ enableRowSelection,
827
+ enableMultiRowSelection,
828
+ getRowId,
371
829
 
372
830
  // Saved views rehydration
373
831
  initialView,
@@ -391,6 +849,13 @@ function DataTableInner<TData, TValue>(
391
849
  maxBodyHeight = "32rem",
392
850
 
393
851
  zebra = true,
852
+ columnDividers = false,
853
+
854
+ // Row drag-reorder (#13)
855
+ enableRowReorder = false,
856
+ onRowReorder,
857
+ rowReorderHandle = "cell",
858
+
394
859
  onRowClick,
395
860
  rowActionLabel,
396
861
  rowClassName,
@@ -403,7 +868,13 @@ function DataTableInner<TData, TValue>(
403
868
  ) {
404
869
  // Component microcopy goes through the locale seam (ADR 0017) — a screen-reader
405
870
  // user in a non-English locale has no workaround for a hardcoded accessible name.
406
- const { t } = useLocale();
871
+ // `dir` also drives column-resize direction below (#12 review, P1): the resize
872
+ // handle already sits at the column's logical `end` edge (`end-0`, which
873
+ // Tailwind's logical properties flip to the physical LEFT under RTL), so both
874
+ // TanStack's own pointer-drag math and the hand-rolled keyboard path must be
875
+ // told the active direction too, or dragging/pressing an arrow moves the width
876
+ // opposite the visible boundary.
877
+ const { t, dir, formatNumber } = useLocale();
407
878
 
408
879
  // ── Controlled/uncontrolled detection ────────────────────────────────────
409
880
  const isSortingControlled = sortingProp !== undefined;
@@ -412,6 +883,8 @@ function DataTableInner<TData, TValue>(
412
883
  const isPaginationControlled = paginationProp !== undefined;
413
884
  const isFilterControlled = globalFilterProp !== undefined;
414
885
  const isColumnPinningControlled = columnPinningProp !== undefined;
886
+ const isColumnSizingControlled = columnSizingProp !== undefined;
887
+ const isRowSelectionControlled = rowSelectionProp !== undefined;
415
888
 
416
889
  // ── Internal state (only drives a slice when uncontrolled) ───────────────
417
890
  const [internalSorting, setInternalSorting] = useState<SortingState>(
@@ -436,6 +909,12 @@ function DataTableInner<TData, TValue>(
436
909
  const [internalColumnPinning, setInternalColumnPinning] = useState<ColumnPinningState>(
437
910
  () => initialView?.columnPinning ?? { left: [], right: [] },
438
911
  );
912
+ const [internalColumnSizing, setInternalColumnSizing] = useState<ColumnSizingState>(
913
+ () => initialView?.columnSizing ?? {},
914
+ );
915
+ const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>(
916
+ () => initialView?.rowSelection ?? {},
917
+ );
439
918
 
440
919
  // ── Resolved state (controlled wins over internal) ───────────────────────
441
920
  const sorting = isSortingControlled ? sortingProp : internalSorting;
@@ -446,6 +925,8 @@ function DataTableInner<TData, TValue>(
446
925
  const pagination = isPaginationControlled ? paginationProp : internalPagination;
447
926
  const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
448
927
  const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
928
+ const columnSizing = isColumnSizingControlled ? columnSizingProp : internalColumnSizing;
929
+ const rowSelection = isRowSelectionControlled ? rowSelectionProp : internalRowSelection;
449
930
 
450
931
  // ── Refs for post-change server callback ─────────────────────────────────
451
932
  // We need the current values of ALL slices when any one fires; use refs to
@@ -462,6 +943,10 @@ function DataTableInner<TData, TValue>(
462
943
  columnVisibilityRef.current = columnVisibility;
463
944
  const columnPinningRef = useRef(columnPinning);
464
945
  columnPinningRef.current = columnPinning;
946
+ const columnSizingRef = useRef(columnSizing);
947
+ columnSizingRef.current = columnSizing;
948
+ const rowSelectionRef = useRef(rowSelection);
949
+ rowSelectionRef.current = rowSelection;
465
950
 
466
951
  // ── Dev-only guard: manualPagination needs a total to compute page count ──
467
952
  // Without `rowCount` (or `pageCount`), TanStack's `getPageCount()` falls back
@@ -486,6 +971,120 @@ function DataTableInner<TData, TValue>(
486
971
  }
487
972
  }, [manualPagination, rowCount, pageCount]);
488
973
 
974
+ // ── Dev-only guard: manualPagination + rowSelection with no getRowId ──────
975
+ // Under `manualPagination` each page IS a fresh `data` array, so TanStack's
976
+ // default index-based row id restarts at `0` on every page — a selection
977
+ // made on page 1's row 0 can silently apply to page 2's row 0 too (#11 I3).
978
+ // Warn once per mount so this footgun is diagnosable instead of silent (same
979
+ // idiom as the #227 warning above). Heuristic, not full usage tracing: fires
980
+ // whenever selection LOOKS wired up (controlled, or a change handler was
981
+ // passed) — it cannot see an uncontrolled table that never renders a
982
+ // selection column at all.
983
+ const warnedManualSelectionRef = useRef(false);
984
+ useEffect(() => {
985
+ if (
986
+ process.env.NODE_ENV !== "production" &&
987
+ manualPagination &&
988
+ getRowId === undefined &&
989
+ (isRowSelectionControlled || onRowSelectionChangeProp !== undefined) &&
990
+ !warnedManualSelectionRef.current
991
+ ) {
992
+ warnedManualSelectionRef.current = true;
993
+ console.warn(
994
+ "[DataTable] `rowSelection` is wired up under `manualPagination` with no `getRowId` " +
995
+ "— each page is a fresh `data` array, so the default index-based id restarts at " +
996
+ '"0" per page and a selection made on one page can silently apply to a different ' +
997
+ "record on the next. Pass `getRowId` so selection is keyed to a stable identity " +
998
+ "instead of position.",
999
+ );
1000
+ }
1001
+ }, [manualPagination, getRowId, isRowSelectionControlled, onRowSelectionChangeProp]);
1002
+
1003
+ // ── Dev-only guard: enableRowReorder + active sorting (#13) ───────────────
1004
+ // Both keep working — this doesn't disable anything — but a sort re-orders
1005
+ // the very rows a drag just moved, which reads as broken rather than merely
1006
+ // confusing. Warn once per mount, same idiom as the two guards above.
1007
+ const warnedReorderSortingRef = useRef(false);
1008
+ useEffect(() => {
1009
+ if (
1010
+ process.env.NODE_ENV !== "production" &&
1011
+ enableRowReorder &&
1012
+ sorting.length > 0 &&
1013
+ !warnedReorderSortingRef.current
1014
+ ) {
1015
+ warnedReorderSortingRef.current = true;
1016
+ console.warn(
1017
+ "[DataTable] `enableRowReorder` is set while a column is sorted — the sort will " +
1018
+ "keep re-ordering rows out from under a manual drag. Clear `sorting` (or avoid " +
1019
+ "enabling both at once) so a drag's new order stays stable.",
1020
+ );
1021
+ }
1022
+ }, [enableRowReorder, sorting.length]);
1023
+
1024
+ // ── Dev-only guard: enableRowReorder + enableRowVirtualization (#13) ──────
1025
+ // A windowed table can't keep dnd-kit's sortable list in sync with a
1026
+ // virtualizer that only mounts a subset of rows, so the two are mutually
1027
+ // exclusive — virtualization wins (same precedent as enablePagination vs.
1028
+ // enableRowVirtualization) and reorder is silently disabled below
1029
+ // (`rowReorderActive`). This warning is the diagnostic for why.
1030
+ const warnedReorderVirtualizedRef = useRef(false);
1031
+ useEffect(() => {
1032
+ if (
1033
+ process.env.NODE_ENV !== "production" &&
1034
+ enableRowReorder &&
1035
+ enableRowVirtualization &&
1036
+ !warnedReorderVirtualizedRef.current
1037
+ ) {
1038
+ warnedReorderVirtualizedRef.current = true;
1039
+ console.warn(
1040
+ "[DataTable] `enableRowReorder` has no effect while `enableRowVirtualization` is " +
1041
+ "set — the two are mutually exclusive. Virtualization wins; row reorder is disabled.",
1042
+ );
1043
+ }
1044
+ }, [enableRowReorder, enableRowVirtualization]);
1045
+
1046
+ // Only wired up in the non-virtualized body — see the warning above.
1047
+ const rowReorderActive = enableRowReorder && !enableRowVirtualization;
1048
+ const hasGripColumn = rowReorderActive && rowReorderHandle === "cell";
1049
+
1050
+ const reorderSensors = useSensors(
1051
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
1052
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1053
+ );
1054
+ // Backing store for `getReorderRowId` (defined below, once `rows` is in
1055
+ // scope) — see its own doc comment for why a WeakMap keyed by row object
1056
+ // reference is the round-1 fix for findings 1 & 3.
1057
+ const reorderIdentityMapRef = useRef<WeakMap<object, string>>(new WeakMap());
1058
+ const reorderIdentityCounterRef = useRef(0);
1059
+ // Positions in `data` whose record REPEATS an object reference that already
1060
+ // appeared earlier in the array — 2nd and later occurrences only (round-2
1061
+ // finding 6). `getReorderRowId` below keys its identity on the record's own
1062
+ // object reference, which is exactly what makes an id survive the array
1063
+ // REPLACEMENT every reorder idiom performs; the cost is that a record the
1064
+ // caller listed twice IS one reference, so both rows would be handed one id
1065
+ // — one React key, one dnd-kit registration, and a drop that can only ever
1066
+ // name the first occurrence. The positions listed here get their own data
1067
+ // index folded into the id so the occurrences stay separately addressable.
1068
+ // Only the repeats are suffixed, so a table with no repeated record keeps
1069
+ // byte-identical ids (and with them the round-1 focus restore).
1070
+ const reorderRepeatedPositions = useMemo(() => {
1071
+ const repeats = new Set<number>();
1072
+ if (!rowReorderActive) return repeats;
1073
+ const seen = new Set<unknown>();
1074
+ data.forEach((record, index) => {
1075
+ if (record === null || typeof record !== "object") return;
1076
+ if (seen.has(record)) repeats.add(index);
1077
+ else seen.add(record);
1078
+ });
1079
+ return repeats;
1080
+ }, [data, rowReorderActive]);
1081
+ // The component's OWN `aria-live="polite"` announcer state — round-1
1082
+ // finding 4 (dnd-kit's built-in region is hardcoded `assertive` with no
1083
+ // override). `reorderLastAnnouncedPositionRef` de-dupes a same-position
1084
+ // re-fire (the pickup self-collision, a no-op arrow press at a boundary).
1085
+ const [reorderLiveMessage, setReorderLiveMessage] = useState("");
1086
+ const reorderLastAnnouncedPositionRef = useRef<number | null>(null);
1087
+
489
1088
  /** Fire onServerChange with the LATEST slice values (post-update). */
490
1089
  function fireServerChange(overrides: Partial<DataTableServerArgs> = {}) {
491
1090
  if (!onServerChange) return;
@@ -526,6 +1125,16 @@ function DataTableInner<TData, TValue>(
526
1125
  ): ColumnPinningState {
527
1126
  return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
528
1127
  }
1128
+ function resolveColumnSizing(
1129
+ updater: Parameters<OnChangeFn<ColumnSizingState>>[0],
1130
+ ): ColumnSizingState {
1131
+ return typeof updater === "function" ? updater(columnSizingRef.current) : updater;
1132
+ }
1133
+ function resolveRowSelection(
1134
+ updater: Parameters<OnChangeFn<RowSelectionState>>[0],
1135
+ ): RowSelectionState {
1136
+ return typeof updater === "function" ? updater(rowSelectionRef.current) : updater;
1137
+ }
529
1138
 
530
1139
  // ── Row models — omit client model for manual slices ─────────────────────
531
1140
  const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
@@ -542,7 +1151,16 @@ function DataTableInner<TData, TValue>(
542
1151
  const table = useReactTable({
543
1152
  data,
544
1153
  columns,
545
- state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
1154
+ state: {
1155
+ sorting,
1156
+ columnVisibility,
1157
+ columnFilters,
1158
+ globalFilter,
1159
+ pagination,
1160
+ columnPinning,
1161
+ columnSizing,
1162
+ rowSelection,
1163
+ },
546
1164
 
547
1165
  // Sorting
548
1166
  onSortingChange: (updater) => {
@@ -605,6 +1223,40 @@ function DataTableInner<TData, TValue>(
605
1223
  onColumnPinningChangeProp?.(updater);
606
1224
  },
607
1225
 
1226
+ // Column resizing (#12) — a LAYOUT slice, like column pinning: a column's
1227
+ // width changes nothing the server would need to re-query, so this never
1228
+ // fires onServerChange either. Routed through by BOTH the pointer path
1229
+ // (TanStack's own `header.getResizeHandler()`, wired below) and the
1230
+ // keyboard path (`handleResizeKeyDown`, via `table.setColumnSizing`) so
1231
+ // the two input modes can never diverge in controlled/uncontrolled
1232
+ // behaviour.
1233
+ columnResizeMode,
1234
+ // RTL fix (#12 review, P1): TanStack's pointer-drag math hardcodes LTR
1235
+ // unless told otherwise — `deltaDirection = columnResizeDirection ===
1236
+ // 'rtl' ? -1 : 1` internally — so under `dir="rtl"` (the resize handle's
1237
+ // own edge already flips via `end-0`, see the `useLocale()` call above)
1238
+ // dragging would otherwise move the column's width opposite the visible
1239
+ // boundary. `handleResizeKeyDown` below mirrors this for the keyboard path.
1240
+ columnResizeDirection: dir,
1241
+ enableColumnResizing,
1242
+ onColumnSizingChange: (updater) => {
1243
+ const next = resolveColumnSizing(updater);
1244
+ if (!isColumnSizingControlled) setInternalColumnSizing(next);
1245
+ onColumnSizingChangeProp?.(updater);
1246
+ },
1247
+
1248
+ // Row selection (#11) — also a LAYOUT/UI slice, so it never fires
1249
+ // onServerChange: which rows are checked changes nothing the server
1250
+ // would need to re-query.
1251
+ onRowSelectionChange: (updater) => {
1252
+ const next = resolveRowSelection(updater);
1253
+ if (!isRowSelectionControlled) setInternalRowSelection(next);
1254
+ onRowSelectionChangeProp?.(updater);
1255
+ },
1256
+ enableRowSelection,
1257
+ enableMultiRowSelection,
1258
+ getRowId,
1259
+
608
1260
  getCoreRowModel: getCoreRowModel(),
609
1261
  ...sortedRowModel,
610
1262
  ...filteredRowModel,
@@ -635,6 +1287,185 @@ function DataTableInner<TData, TValue>(
635
1287
  const headerRowCount = table.getHeaderGroups().length;
636
1288
  const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
637
1289
 
1290
+ // ── Row drag-reorder (#13) ────────────────────────────────────────────────
1291
+ // `rowActionName` (defined below, but hoisted as a function declaration) is
1292
+ // the SAME row-naming lookup `onRowClick`'s hidden button uses (#337) —
1293
+ // reusing it means a reorder announcement names a row exactly the way its
1294
+ // click target already does, rather than inventing a second convention.
1295
+ function reorderRowName(id: string): string {
1296
+ const row = rows.find((r) => getReorderRowId(r) === id);
1297
+ return row ? rowActionName(row) : id;
1298
+ }
1299
+ function reorderPosition(id: string): number {
1300
+ return rows.findIndex((r) => getReorderRowId(r) === id) + 1;
1301
+ }
1302
+
1303
+ // ── Stable identity for drag reconciliation (round-1 fix, findings 1 & 3) ──
1304
+ // `getRowId`'s own doc comment above states TanStack's fallback: default row
1305
+ // ids are assigned ONCE per row object when the core row model is built from
1306
+ // the current `data` ARRAY REFERENCE, then carried by reference through
1307
+ // sort/filter — but a `data` array REPLACEMENT (exactly what every
1308
+ // `onRowReorder` consumer does: `arrayMove`/`slice`+`splice`/immer all
1309
+ // return a new array) rebuilds the core row model and reassigns ids by
1310
+ // POSITION IN THE NEW ARRAY. So the id that used to denote "the row now at
1311
+ // index 1" keeps denoting index 1 even though a different record moved
1312
+ // there — which is what let a keyboard drop leave focus on the wrong row
1313
+ // (a different record now sits at the id the focus restore targets).
1314
+ // Requiring every consumer to hand-roll `getRowId` would leave the DEFAULT
1315
+ // configuration broken, so when the caller hasn't supplied one, mint an id
1316
+ // keyed by the row's own OBJECT REFERENCE (`row.original`) in a `WeakMap` —
1317
+ // unlike TanStack's default, this id follows the object wherever it lands
1318
+ // in a new array, because every reorder idiom MOVES the element reference,
1319
+ // it never clones it. When `getRowId` IS supplied it is already exactly
1320
+ // this kind of identity, so it's reused as-is instead of minting a second,
1321
+ // divergent id namespace.
1322
+ function getReorderRowId(row: Row<TData>): string {
1323
+ if (getRowId) return row.id;
1324
+ const original: unknown = row.original;
1325
+ if (original !== null && typeof original === "object") {
1326
+ const map = reorderIdentityMapRef.current;
1327
+ let id = map.get(original);
1328
+ if (id === undefined) {
1329
+ id = `__reorder-${reorderIdentityCounterRef.current++}`;
1330
+ map.set(original, id);
1331
+ }
1332
+ // A repeated record shares ONE object reference, so the id minted above
1333
+ // is by construction identical for both of its rows — round-2 finding
1334
+ // 6. Fold the data position into the repeats so each occupant is its
1335
+ // own draggable. Two identical records are interchangeable to the user,
1336
+ // so the weaker cross-replacement stability of a suffixed id costs
1337
+ // nothing the first-occurrence rule doesn't already give back.
1338
+ return reorderRepeatedPositions.has(row.index) ? `${id}__${row.index}` : id;
1339
+ }
1340
+ // Primitive `TData` (rare) has no object reference to key off — same
1341
+ // documented limitation `getRowId`'s own comment already carries for
1342
+ // TanStack's own default identity.
1343
+ return row.id;
1344
+ }
1345
+
1346
+ // dnd-kit's own `Accessibility` component's `LiveRegion` hardcodes
1347
+ // `aria-live="assertive"` with no way to override it from `DndContext`
1348
+ // (`@dnd-kit/accessibility` 3.1.1 accepts an `ariaLiveType` prop on
1349
+ // `LiveRegion` itself, but nothing forwards one through `accessibility`) —
1350
+ // round-1 finding 4. `.claude/rules/accessibility.md` reserves assertive
1351
+ // for terminal errors (`role="alert"`); a sortable list's own position
1352
+ // updates are `polite` status. So dnd-kit's built-in announcer is silenced
1353
+ // below (every callback returns `undefined`, which `useAnnouncement`
1354
+ // treats as "no update" — the region stays permanently empty and never
1355
+ // fires) and DataTable renders its OWN `aria-live="polite"` region
1356
+ // (`reorderLiveMessage`, wired to the `data-table-reorder-live-region`
1357
+ // node near the bottom of this function) from the `onDragStart`/
1358
+ // `onDragOver`/`onDragEnd`/`onDragCancel` handlers below.
1359
+ const silentDragAnnouncements: Announcements = {
1360
+ onDragStart: () => undefined,
1361
+ onDragOver: () => undefined,
1362
+ onDragEnd: () => undefined,
1363
+ onDragCancel: () => undefined,
1364
+ };
1365
+
1366
+ /**
1367
+ * Pickup always announces — it's the start of a new, meaningful gesture.
1368
+ * Seeding `reorderLastAnnouncedPositionRef` with the row's OWN starting
1369
+ * position (not `null`) is what suppresses dnd-kit's immediate self-
1370
+ * collision `onDragOver` (over === active, at the same position) that
1371
+ * otherwise fires in the same tick and would stomp this message before it
1372
+ * is ever observable (WCAG 4.1.3 needs it heard, not just rendered).
1373
+ */
1374
+ function handleRowDragStart(event: DragStartEvent) {
1375
+ const activeRowId = String(event.active.id);
1376
+ reorderLastAnnouncedPositionRef.current = reorderPosition(activeRowId);
1377
+ setReorderLiveMessage(t("data.table.reorderPickedUp", { name: reorderRowName(activeRowId) }));
1378
+ }
1379
+
1380
+ /**
1381
+ * Announces a real position change only — round-1 finding 4 measured 4
1382
+ * announcements for a 2-step move, one of them a same-position self-
1383
+ * collision that buried the "picked up" message. De-duping on the actual
1384
+ * computed position (not on the raw event) means a screen reader hears one
1385
+ * `polite` (queued, non-interrupting) announcement per genuine move, not
1386
+ * one per keystroke.
1387
+ */
1388
+ function handleRowDragOver(event: DragOverEvent) {
1389
+ const { active, over } = event;
1390
+ if (!over) return;
1391
+ const position = reorderPosition(String(over.id));
1392
+ if (position === reorderLastAnnouncedPositionRef.current) return;
1393
+ reorderLastAnnouncedPositionRef.current = position;
1394
+ setReorderLiveMessage(
1395
+ t("data.table.reorderMoved", {
1396
+ name: reorderRowName(String(active.id)),
1397
+ position,
1398
+ total: rows.length,
1399
+ }),
1400
+ );
1401
+ }
1402
+
1403
+ function handleRowDragCancel(event: DragCancelEvent) {
1404
+ const activeRowId = String(event.active.id);
1405
+ setReorderLiveMessage(
1406
+ t("data.table.reorderCancelled", {
1407
+ name: reorderRowName(activeRowId),
1408
+ position: reorderPosition(activeRowId),
1409
+ total: rows.length,
1410
+ }),
1411
+ );
1412
+ reorderLastAnnouncedPositionRef.current = null;
1413
+ }
1414
+
1415
+ /**
1416
+ * The component never mutates `data` itself (D5 — presentation layer, not
1417
+ * an SDK): it only reports the move, the same "controlled slice" contract
1418
+ * every other DataTable feature follows. A no-op drop (dropped on itself,
1419
+ * or outside any droppable) fires nothing on the data callback, but still
1420
+ * announces (matching the "dropped back where it started" reality).
1421
+ *
1422
+ * `from`/`to` resolve against the ORIGINAL `data` array the caller passed
1423
+ * in, never against the sorted/paginated VIEW (`rows`) — round-1 finding 1.
1424
+ * Reporting `rows.findIndex(...)` positions meant a caller doing
1425
+ * `arrayMove(data, from, to)` (the idiom both shipped stories use) silently
1426
+ * moved the WRONG records whenever an active sort or a client-side page
1427
+ * had changed which record sat at which view position — measured: a
1428
+ * paginated drag on page 2 reported `(0, 1, …)`, corrupting `data[0]`/
1429
+ * `data[1]` on page 1. Resolving against `data` itself makes the contract
1430
+ * "indices into the `data` you gave me" — correct under any sort/filter,
1431
+ * correct under client-side pagination (the dragged record's true index in
1432
+ * the full array), and correct under `manualPagination` too (there `data`
1433
+ * IS the current page, so `from`/`to` are page-relative, which is exactly
1434
+ * what a caller reordering that page's own array needs).
1435
+ */
1436
+ function handleRowDragEnd(event: DragEndEvent) {
1437
+ const { active, over } = event;
1438
+ const activeRowId = String(active.id);
1439
+ setReorderLiveMessage(
1440
+ t("data.table.reorderDropped", {
1441
+ name: reorderRowName(activeRowId),
1442
+ position: reorderPosition(String(over ? over.id : active.id)),
1443
+ total: rows.length,
1444
+ }),
1445
+ );
1446
+ reorderLastAnnouncedPositionRef.current = null;
1447
+
1448
+ if (!over || active.id === over.id) return;
1449
+ const movedRow = rows.find((r) => getReorderRowId(r) === activeRowId);
1450
+ const targetRow = rows.find((r) => getReorderRowId(r) === String(over.id));
1451
+ if (!movedRow || !targetRow) return;
1452
+ // Round-2 finding 6: this used to build a `Map` keyed by `row.original`
1453
+ // and read `from`/`to` out of it. A `data` array that repeats a record —
1454
+ // the same object reference, or the same primitive, at two positions —
1455
+ // can only occupy ONE slot in such a map, so the later occurrence was
1456
+ // reported as the earlier one and the documented `arrayMove(data, from,
1457
+ // to)` idiom moved a row the user never dragged, silently. `Row.index` is
1458
+ // the position TanStack already assigned this row when it built the core
1459
+ // row model FROM `data`, carried by reference through sort/filter/
1460
+ // pagination (the same property the round-1 fix above relies on) — so it
1461
+ // keeps the "indices into the `data` you gave me" contract without the
1462
+ // value-equality lookup that collapsed the repeats.
1463
+ const from = movedRow.index;
1464
+ const to = targetRow.index;
1465
+ if (from < 0 || from >= data.length || to < 0 || to >= data.length) return;
1466
+ onRowReorder?.(from, to, movedRow.original);
1467
+ }
1468
+
638
1469
  // ── Pinning (#333) ────────────────────────────────────────────────────────
639
1470
  // Are there any pinned columns at all? Everything pinning-related is gated on
640
1471
  // this so a table with no pinning renders byte-identical markup to before.
@@ -736,7 +1567,7 @@ function DataTableInner<TData, TValue>(
736
1567
  // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
737
1568
  // seam pixel read `143,143,143` (light `--border-strong`) at
738
1569
  // scrollLeft 0 and `245,245,245` (the plain cell fill — i.e. GONE) once
739
- // scrolled, in all three themes and on both edges. So the one cue vanished
1570
+ // scrolled, in every theme and on both edges. So the one cue vanished
740
1571
  // exactly when the freeze was doing something. The `::after` lives in the
741
1572
  // sticky cell's own stacking context, so it moves with it.
742
1573
  edgeClass:
@@ -750,6 +1581,76 @@ function DataTableInner<TData, TValue>(
750
1581
  };
751
1582
  }
752
1583
 
1584
+ // ── Column resizing keyboard path (#12) ───────────────────────────────────
1585
+ // TanStack's own `header.getResizeHandler()` is pointer/touch-only — no
1586
+ // keyboard path exists in the library — so the WAI-ARIA separator-as-slider
1587
+ // practice (drag handle operable via ArrowLeft/ArrowRight when focused)
1588
+ // needs one small hand-rolled step. It goes through `table.setColumnSizing`
1589
+ // (`table.setColumnSizing = updater => table.options.onColumnSizingChange
1590
+ // ?.(updater)`, TanStack's own `ColumnSizing` feature), which is the SAME
1591
+ // `onColumnSizingChange` handler passed to `useReactTable` above — so
1592
+ // keyboard and pointer resizing share one controlled/uncontrolled code path
1593
+ // and can never diverge in behaviour.
1594
+ const RESIZE_STEP = 10;
1595
+ // ARIA fallback ceiling for the resize separator's `aria-valuemax` when the
1596
+ // column declares no explicit `maxSize` — a `ColumnDef` with no `maxSize`
1597
+ // resolves through TanStack's own default to `Number.MAX_SAFE_INTEGER`,
1598
+ // which is not a value any AT should announce, so the header below omits
1599
+ // `aria-valuemax` entirely in that case. Per the WAI-ARIA separator-as-
1600
+ // widget pattern, an ELEMENT WITH NO `aria-valuemax` is read with an
1601
+ // IMPLICIT default of 100 — so a column at its ordinary starting width
1602
+ // (150) already announces as "150 of 100", out of its own stated range
1603
+ // (#12 review, P2). `Math.max` with the live size at the call site below
1604
+ // keeps this always containing the current value: a column dragged past
1605
+ // this floor simply raises its own announced ceiling instead of going out
1606
+ // of range again.
1607
+ const RESIZE_UNBOUNDED_ARIA_MAX = 2000;
1608
+ function handleResizeKeyDown(event: React.KeyboardEvent, column: Column<TData, unknown>) {
1609
+ let delta = 0;
1610
+ if (event.key === "ArrowRight") delta = RESIZE_STEP;
1611
+ else if (event.key === "ArrowLeft") delta = -RESIZE_STEP;
1612
+ else return;
1613
+ event.preventDefault();
1614
+ // Mirror TanStack's own `columnResizeDirection` reversal (passed to
1615
+ // `useReactTable` above) for the keyboard path: the handle sits at the
1616
+ // column's logical `end` edge, which `end-0` renders on the physical
1617
+ // LEFT under `dir="rtl"` — so ArrowRight (physical right, toward the
1618
+ // column's own body) must SHRINK the column and ArrowLeft must GROW it,
1619
+ // the mirror image of LTR. Without this the keyboard path would diverge
1620
+ // from the now-direction-aware pointer path.
1621
+ if (dir === "rtl") delta = -delta;
1622
+ const minSize = column.columnDef.minSize ?? 20;
1623
+ const maxSize = column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
1624
+ const nextSize = Math.min(maxSize, Math.max(minSize, column.getSize() + delta));
1625
+ table.setColumnSizing((old) => ({ ...old, [column.id]: nextSize }));
1626
+ }
1627
+
1628
+ // #51 — double-click resets a resize handle's column back to its declared
1629
+ // `ColumnDef.size`, falling back to TanStack's own default (150, the same
1630
+ // fallback idiom as `minSize ?? 20`/`maxSize ?? MAX_SAFE_INTEGER` above) when
1631
+ // the author left it unset — by REMOVING any explicit `columnSizing` entry
1632
+ // for the column, not by writing the size back in as a literal (PR #81
1633
+ // review, "Remove the sizing override when resetting a column"). `columnSizing`
1634
+ // only ever carries EXPLICIT per-column overrides; a column absent from it
1635
+ // always tracks its live `ColumnDef.size` (or the 150 default). Writing the
1636
+ // CURRENT declared size back in as a value looks identical today but turns
1637
+ // the default into a permanent override: if the `columns` prop later
1638
+ // changes this column's authored `size` (e.g. switching table
1639
+ // configurations), a column that was never resized follows the new
1640
+ // definition for free, while a double-click-reset column would stay pinned
1641
+ // to the OLD number forever. Deleting the entry keeps it dynamic, exactly
1642
+ // like a column that was never touched. Still goes through the SAME
1643
+ // `table.setColumnSizing` dispatch path as `handleResizeKeyDown` — never
1644
+ // `column.resetSize()` — so a controlled `columnSizing` consumer observes
1645
+ // the reset via `onColumnSizingChange` exactly like every other resize.
1646
+ function handleResizeDoubleClick(column: Column<TData, unknown>) {
1647
+ table.setColumnSizing((old) => {
1648
+ if (!(column.id in old)) return old;
1649
+ const { [column.id]: _removed, ...rest } = old;
1650
+ return rest;
1651
+ });
1652
+ }
1653
+
753
1654
  // ── Scroll container ref for virtualizer ─────────────────────────────────
754
1655
  const scrollRef = useRef<HTMLDivElement>(null);
755
1656
 
@@ -839,6 +1740,11 @@ function DataTableInner<TData, TValue>(
839
1740
  >
840
1741
  {table.getHeaderGroups().map((headerGroup, groupIndex) => (
841
1742
  <tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
1743
+ {hasGripColumn && (
1744
+ <th key="__reorder" scope="col" className="h-10 w-10 px-3 align-middle">
1745
+ <span className="sr-only">{t("data.table.reorderColumnHeader")}</span>
1746
+ </th>
1747
+ )}
842
1748
  {headerGroup.headers.map((header) => {
843
1749
  const geometry = pinnedCellGeometry(header.column);
844
1750
  const canSort = header.column.getCanSort();
@@ -853,6 +1759,15 @@ function DataTableInner<TData, TValue>(
853
1759
  sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
854
1760
  const SortIcon =
855
1761
  sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
1762
+ // #12: every column gets the same explicit width triad a pinned
1763
+ // column already has, gated behind `enableColumnResizing` so a
1764
+ // table that doesn't opt in stays byte-identical to before.
1765
+ const resizeStyle = enableColumnResizing
1766
+ ? resizeWidthStyle(header.getSize())
1767
+ : undefined;
1768
+ const canResize =
1769
+ enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
1770
+ const resizeMax = header.column.columnDef.maxSize;
856
1771
  return (
857
1772
  <th
858
1773
  key={header.id}
@@ -867,9 +1782,24 @@ function DataTableInner<TData, TValue>(
867
1782
  : undefined
868
1783
  }
869
1784
  data-pinned={geometry?.pinned ?? undefined}
870
- style={geometry?.style}
1785
+ style={geometry?.style ?? resizeStyle}
871
1786
  className={cn(
872
- "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
1787
+ // Same `px-3` the body `<td>` uses (below) — deliberately
1788
+ // NOT split into `ps-3`/`pe-3` for a resize-handle
1789
+ // override (round-1 briefly did this, see the round-2
1790
+ // note on `numericColumnClasses`): the header's padding
1791
+ // must stay byte-identical to the body's so an
1792
+ // end-aligned numeric column's header lines up with its
1793
+ // own values.
1794
+ "h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
1795
+ // #69: a numeric column's `meta` overrides the default
1796
+ // `text-start` — placed right after the base string so
1797
+ // tailwind-merge lets it win over that default.
1798
+ numericColumnClasses(header.column.columnDef.meta),
1799
+ // `sticky`/pinned already establishes a positioning context
1800
+ // for the resize handle's `absolute`; an unpinned resizable
1801
+ // header needs its own.
1802
+ !geometry && canResize && "relative",
873
1803
  // A pinned HEADER cell is the corner where both freezes meet,
874
1804
  // so it stacks above the sticky header row (z-20) which is
875
1805
  // above the pinned body cells (z-10). It needs an OPAQUE
@@ -896,6 +1826,7 @@ function DataTableInner<TData, TValue>(
896
1826
  // it must not read as a "boundary + fill in one class string"
897
1827
  // redundancy (separation:check).
898
1828
  geometry?.edgeClass,
1829
+ columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
899
1830
  )}
900
1831
  >
901
1832
  {header.isPlaceholder ? null : canSort ? (
@@ -903,7 +1834,31 @@ function DataTableInner<TData, TValue>(
903
1834
  type="button"
904
1835
  onClick={header.column.getToggleSortingHandler()}
905
1836
  aria-label={`Sort by ${headerLabel}, ${sortStateLabel}`}
906
- className="inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1837
+ // `relative z-10` (round-2 fix, #82 follow-up replaces
1838
+ // round-1's padding-based clearance, see the note on
1839
+ // `numericColumnClasses`): on a resizable column the
1840
+ // resize handle below is `absolute`, and CSS painting
1841
+ // order always puts a positioned descendant above
1842
+ // non-positioned in-flow content in the SAME stacking
1843
+ // context, regardless of DOM order — so without this,
1844
+ // the handle's 24px hit box would win every hit-test
1845
+ // where it overlaps this button's own trailing edge
1846
+ // (measured: a 12px overlap on an end-aligned
1847
+ // sortable+resizable column) no matter which element
1848
+ // renders first in markup. Giving the button its own
1849
+ // explicit positive z-index (not just `relative`, which
1850
+ // alone would still lose — see the code comment on
1851
+ // `numericColumnClasses` above) promotes it into a
1852
+ // later, higher-stacked paint step than the handle's
1853
+ // implicit `z-index: auto`, so the button wins the
1854
+ // overlap purely at the hit-test/paint layer — the
1855
+ // header's padding, and therefore its alignment with
1856
+ // the body `<td>`, never has to move. The handle's own
1857
+ // visible drag affordance (the `after:` seam, 0-8px
1858
+ // from the cell's trailing edge) sits entirely outside
1859
+ // this button's box (which ends at the same 12px inset
1860
+ // as the body), so dragging is unaffected.
1861
+ className="relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring"
907
1862
  >
908
1863
  {flexRender(header.column.columnDef.header, header.getContext())}
909
1864
  <SortIcon
@@ -914,6 +1869,106 @@ function DataTableInner<TData, TValue>(
914
1869
  ) : (
915
1870
  flexRender(header.column.columnDef.header, header.getContext())
916
1871
  )}
1872
+ {canResize && (
1873
+ <div
1874
+ role="separator"
1875
+ aria-orientation="vertical"
1876
+ aria-valuenow={Math.round(header.getSize())}
1877
+ aria-valuemin={header.column.columnDef.minSize}
1878
+ aria-valuemax={
1879
+ resizeMax !== undefined && resizeMax < Number.MAX_SAFE_INTEGER
1880
+ ? resizeMax
1881
+ : Math.max(header.getSize(), RESIZE_UNBOUNDED_ARIA_MAX)
1882
+ }
1883
+ // #51: a bare number reads to AT as a dimensionless
1884
+ // ordinal ("150") rather than a size — aria-valuetext
1885
+ // supplies the unit while aria-valuenow (above) stays
1886
+ // the plain numeric value TanStack/AT expect. PR #81
1887
+ // review, "Format the announced resize value for the
1888
+ // active locale": `count` (the raw number) drives
1889
+ // PluralMessage category selection so a locale whose
1890
+ // plural rules pick something other than "other" is
1891
+ // reachable, and `size` goes through `formatNumber` so
1892
+ // an overriding locale renders its own digits/grouping
1893
+ // instead of a raw Latin-digit JS number.
1894
+ aria-valuetext={t("data.table.resizeColumnValue", {
1895
+ count: Math.round(header.getSize()),
1896
+ size: formatNumber(Math.round(header.getSize())),
1897
+ })}
1898
+ aria-label={t("data.table.resizeColumn", { name: headerLabel })}
1899
+ tabIndex={0}
1900
+ data-slot="data-table-resize-handle"
1901
+ onMouseDown={header.getResizeHandler()}
1902
+ onTouchStart={header.getResizeHandler()}
1903
+ onKeyDown={(event) => handleResizeKeyDown(event, header.column)}
1904
+ // #51: double-click resets the column to its declared
1905
+ // (or default) size — see `handleResizeDoubleClick`.
1906
+ // Pointer-only; it doesn't touch the keyboard path above.
1907
+ onDoubleClick={() => handleResizeDoubleClick(header.column)}
1908
+ className={cn(
1909
+ // #51: the hit box is a literal 24px (clamped to half
1910
+ // the header cell so it can never overlap a neighbour,
1911
+ // even at `minSize=20`) rather than the `w-2` Tailwind
1912
+ // spacing-scale utility. `w-2` compiles to
1913
+ // `calc(var(--spacing) * 2)`, and `--spacing` is what
1914
+ // `data-density="compact"` rescales — so the old 8px
1915
+ // hit box shrank further under compact density
1916
+ // (~7.1px). A literal px value is density-independent
1917
+ // by construction, which is the actual defect the
1918
+ // maintainer's review corrected (NOT `--type-factor`,
1919
+ // which this handle never used). Do not widen via
1920
+ // overhang into the neighbouring cell instead — on the
1921
+ // last column that lands inside the `overflow-auto`
1922
+ // box (#330 false positive) and a pinned neighbour
1923
+ // paints over/hit-tests away the extra area.
1924
+ "absolute inset-y-0 end-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
1925
+ // #51: the focus ring moves to the `after:` pseudo-
1926
+ // element (the drawn seam) rather than the box itself
1927
+ // — the box is now a 24px hit target, and a 24px focus
1928
+ // rectangle would replace the deliberately slim ring
1929
+ // already reviewed/approved as the #12 a11y fix
1930
+ // (da9b29e). `focus-visible:after:*` targets the
1931
+ // pseudo-element the same way `hover:after:w-2` /
1932
+ // `focus-visible:after:w-2` below already do.
1933
+ "focus-visible:outline-none",
1934
+ // a11y fix (#12 review, blocking): this handle is the
1935
+ // SOLE boundary between two adjacent header cells once
1936
+ // resizing is on — no fill/elevation change separates
1937
+ // them otherwise — so per the border/border-strong
1938
+ // decision test (styling-and-tokens.md) it needs a
1939
+ // rung that clears WCAG 1.4.11's 3:1 on its OWN, in
1940
+ // EVERY state, including rest (a control with no
1941
+ // affordance until hover is unusable without a
1942
+ // pointer). `border-strong` measures only 2.86-2.96:1
1943
+ // against this `bg-surface-muted` header — that rung
1944
+ // is guaranteed only vs `--card`/`--background`, not a
1945
+ // same-tone surface, which is the exact trap the rule
1946
+ // warns about. `muted-foreground` is guaranteed AA
1947
+ // text contrast against `--surface-muted`
1948
+ // (TEXT_SURFACES), so it clears the 3:1 non-text
1949
+ // minimum with wide margin (measured ~5.3-6.4:1 in
1950
+ // both themes, unaffected by density) and is already
1951
+ // the header's own label color. A slim persistent
1952
+ // `after:` seam (not just a hover reveal) gives the
1953
+ // real resting boundary; hover/focus widen the drawn
1954
+ // seam to 8px (`after:w-2`) using the same compliant
1955
+ // color — a separate width from the 24px pointer hit
1956
+ // box below (#51), which the seam does not fill.
1957
+ // Dragging keeps the pre-existing full-fill
1958
+ // `bg-primary` treatment — that is a drag AFFORDANCE,
1959
+ // not a focus indicator, and it is redundant with the
1960
+ // pointer capture, so it is out of scope here. The
1961
+ // keyboard focus indicator on both branches is the
1962
+ // shared compound one (#67), applied to the drawn seam
1963
+ // via `focus-visible:after:focus-ring-static`: the
1964
+ // element itself is a 24px transparent hit box, so
1965
+ // ringing IT would ring nothing a user can see.
1966
+ header.column.getIsResizing()
1967
+ ? "after:absolute after:inset-y-0 after:end-0 after:w-2 after:bg-primary after:content-[''] focus-visible:after:focus-ring-static"
1968
+ : "after:absolute after:inset-y-0 after:end-0 after:w-px after:bg-muted-foreground after:content-[''] hover:after:w-2 focus-visible:after:w-2 focus-visible:after:focus-ring-static",
1969
+ )}
1970
+ />
1971
+ )}
917
1972
  </th>
918
1973
  );
919
1974
  })}
@@ -928,17 +1983,25 @@ function DataTableInner<TData, TValue>(
928
1983
  * under virtualization (a CSS `even:`/`odd:` variant would "swim" as the
929
1984
  * windowed `<tr>`s recycle).
930
1985
  *
931
- * - zebra (default): a gentle `foreground/5` wash on alternate rows is the ONE
1986
+ * - zebra (default): a gentle `--table-stripe` wash on alternate rows is the ONE
932
1987
  * separation gesture; rows carry NO divider (#173's strong divider was the cue
933
1988
  * only because nothing else was — the stripe replaces it, so a border would now
934
- * be redundant per the surface-separation rule).
1989
+ * be redundant per the surface-separation rule). A theme that turns the stripe
1990
+ * off (`--table-stripe: transparent`) sets `--table-row-rule-width` to put the
1991
+ * strong divider back as the sole cue; it is `0px` by default, so the stock
1992
+ * stripe carries no border and no extra pixel.
935
1993
  * - lines (`zebra={false}`): the classic `border-border-strong` divider between
936
1994
  * rows; `last:border-b-0` so the final divider doesn't double with the
937
1995
  * container's own bottom border (which reads as a heavy edge / shadow).
938
1996
  */
939
1997
  function rowSeparationClass(rowIndex: number): string {
940
1998
  if (!zebra) return "border-b border-border-strong last:border-b-0";
941
- return rowIndex % 2 === 1 ? "bg-foreground/5" : "";
1999
+ return cn(
2000
+ "border-b-(length:--table-row-rule-width) border-border-strong last:border-b-0",
2001
+ // Separate cn() argument: the stripe and the (theme-gated) rule are
2002
+ // alternative cues, never both at once — see the jsdoc above.
2003
+ rowIndex % 2 === 1 && "bg-table-stripe",
2004
+ );
942
2005
  }
943
2006
 
944
2007
  /**
@@ -976,25 +2039,24 @@ function DataTableInner<TData, TValue>(
976
2039
  return cn(
977
2040
  "bg-card",
978
2041
  "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
979
- zebra && rowIndex % 2 === 1 && "before:bg-foreground/5",
980
- "group-hover/row:before:bg-foreground/10",
2042
+ zebra && rowIndex % 2 === 1 && "before:bg-table-stripe",
2043
+ "group-hover/row:before:bg-table-row-hover",
981
2044
  "group-data-[state=selected]/row:before:bg-accent",
982
2045
  );
983
2046
  }
984
2047
 
985
2048
  /**
986
2049
  * Accessible name for a row's hidden activation button (#337). Prefers the
987
- * caller's `rowActionLabel`, then the first visible cell's primitive value
988
- * (the row's primary identifier the same name a link in that cell would
989
- * get, so screen-reader users hear "billing, button", not five identically
990
- * named buttons), then the localized generic fallback.
2050
+ * caller's `rowActionLabel`, then the row's first DATA column value (via
2051
+ * `firstDataCellValue`skips a leading display column with no accessor,
2052
+ * e.g. `createSelectionColumn()`'s own checkbox column, #11 I6), then the
2053
+ * localized generic fallback.
991
2054
  */
992
2055
  function rowActionName(row: (typeof rows)[number]): string {
993
2056
  const explicit = rowActionLabel?.(row);
994
2057
  if (explicit) return explicit;
995
- const firstValue = row.getVisibleCells()[0]?.getValue();
996
- if (typeof firstValue === "string" && firstValue.trim() !== "") return firstValue;
997
- if (typeof firstValue === "number") return String(firstValue);
2058
+ const name = firstDataCellValue(row);
2059
+ if (name !== undefined) return name;
998
2060
  return t("data.table.rowAction");
999
2061
  }
1000
2062
 
@@ -1003,6 +2065,18 @@ function DataTableInner<TData, TValue>(
1003
2065
  row: (typeof rows)[number],
1004
2066
  rowIndex: number,
1005
2067
  extras?: React.HTMLAttributes<HTMLTableRowElement>,
2068
+ // Reorder metadata for THIS row, present in either handle mode whenever
2069
+ // reorder is active — `activator` is set only in `"cell"` mode, where the
2070
+ // grip button (not the row) is the drag activator (dnd-kit's
2071
+ // `setActivatorNodeRef` pattern).
2072
+ dragHandle?: {
2073
+ isDragging: boolean;
2074
+ activator?: {
2075
+ setActivatorNodeRef: (node: HTMLElement | null) => void;
2076
+ attributes: DraggableAttributes;
2077
+ listeners: DraggableSyntheticListeners;
2078
+ };
2079
+ },
1006
2080
  ) {
1007
2081
  // #337: `onRowClick` adds exactly ONE activation target per row — a
1008
2082
  // visually-hidden <button> in the first cell. The <tr> stays a plain `row`
@@ -1034,7 +2108,17 @@ function DataTableInner<TData, TValue>(
1034
2108
  // (only movement is neutralized); the gated duration-fast/ease-standard
1035
2109
  // pair already collapses toward ~0ms via --motion-factor when the user
1036
2110
  // or OS asks for reduced motion, matching the header sort button.
1037
- "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
2111
+ "transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-accent",
2112
+ // #13: the dragged row's live `transform` (set inline via `extras.style`,
2113
+ // see `SortableDataRow`) is what actually MOVES it — this class only
2114
+ // makes that movement glide instead of snapping, through the gated
2115
+ // duration/ease utilities (never a raw ms/ease value —
2116
+ // quality-gates.md "Motion-tokened") with a reduced-motion
2117
+ // neutralizer. Raising the dragged row's stacking + opacity is a
2118
+ // colour/composite-only cue, so it isn't gated by the same rule.
2119
+ dragHandle &&
2120
+ "relative transition-transform duration-base ease-standard motion-reduce:transition-none",
2121
+ dragHandle?.isDragging && "z-20 opacity-90 shadow-md",
1038
2122
  // Named group (#333) so a PINNED cell can re-apply the row's hover /
1039
2123
  // selected wash on top of its own opaque fill — only CSS knows the
1040
2124
  // pointer is over a sibling cell. Purely a selector hook: `group/row`
@@ -1048,33 +2132,64 @@ function DataTableInner<TData, TValue>(
1048
2132
  // ring paints on the ROW the user is about to activate, even though
1049
2133
  // focus lives on the sr-only control inside it.
1050
2134
  clickable &&
1051
- "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:outline-2 has-[[data-slot=data-table-row-action]:focus-visible]:-outline-offset-2 has-[[data-slot=data-table-row-action]:focus-visible]:outline-ring",
2135
+ "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:focus-ring-static-inset",
1052
2136
  rowClassName?.(row),
1053
2137
  )}
1054
2138
  {...extras}
1055
2139
  >
2140
+ {dragHandle?.activator && (
2141
+ <td className="w-10 px-3 py-2 align-middle">
2142
+ <button
2143
+ type="button"
2144
+ ref={dragHandle.activator.setActivatorNodeRef}
2145
+ data-slot="data-table-row-drag-handle"
2146
+ aria-label={t("data.table.reorderHandle", { name: rowActionName(row) })}
2147
+ className={cn(
2148
+ "inline-flex size-7 cursor-grab items-center justify-center rounded-sm text-muted-foreground transition-colors duration-fast ease-standard hover:bg-foreground/10 hover:text-foreground focus-ring active:cursor-grabbing",
2149
+ dragHandle.isDragging && "text-foreground",
2150
+ )}
2151
+ {...dragHandle.activator.attributes}
2152
+ {...dragHandle.activator.listeners}
2153
+ >
2154
+ <GripVertical aria-hidden="true" className="size-4" />
2155
+ </button>
2156
+ </td>
2157
+ )}
1056
2158
  {row.getVisibleCells().map((cell, cellIndex) => {
1057
2159
  const geometry = pinnedCellGeometry(cell.column);
2160
+ // #12: same width triad as the header cell — see `resizeWidthStyle`.
2161
+ const resizeStyle = enableColumnResizing
2162
+ ? resizeWidthStyle(cell.column.getSize())
2163
+ : undefined;
1058
2164
  return (
1059
2165
  <td
1060
2166
  key={cell.id}
1061
2167
  data-pinned={geometry?.pinned ?? undefined}
1062
- style={geometry?.style}
2168
+ style={geometry?.style ?? resizeStyle}
1063
2169
  className={cn(
1064
2170
  "px-3 py-2 align-middle",
2171
+ // #69: same numeric-column seam as the header — see
2172
+ // `numericColumnClasses`.
2173
+ numericColumnClasses(cell.column.columnDef.meta),
1065
2174
  // z-10: above the normal (unpositioned) cells it scrolls over,
1066
2175
  // below the sticky header row (z-20) and the pinned corner (z-30).
1067
2176
  geometry && "sticky z-10",
1068
2177
  geometry && pinnedCellFillClass(rowIndex),
1069
2178
  // Separate cn() argument — see pinnedCellGeometry's edgeClass.
1070
2179
  geometry?.edgeClass,
2180
+ columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
1071
2181
  )}
1072
2182
  >
1073
2183
  {clickable && cellIndex === 0 && (
1074
2184
  <button
1075
2185
  type="button"
1076
2186
  data-slot="data-table-row-action"
1077
- className="sr-only"
2187
+ // #311: `sr-only` removes the box from the visual layout but
2188
+ // not the browser's own focus ring — the ROW paints the
2189
+ // deliberate compound indicator (via the `has-[…]` selector
2190
+ // above), so the proxy's own native ring must be suppressed
2191
+ // or it leaks as a stray dot at the row's edge.
2192
+ className="sr-only focus-visible:outline-none"
1078
2193
  onClick={(event) => onRowClick?.(row, event)}
1079
2194
  >
1080
2195
  {rowActionName(row)}
@@ -1093,10 +2208,28 @@ function DataTableInner<TData, TValue>(
1093
2208
  * renderers so a markup/token/a11y fix only needs to be made once (#231).
1094
2209
  */
1095
2210
  function renderSkeletonBody(count: number) {
2211
+ // #69: iterate the real leaf columns (not just a count) so each skeleton
2212
+ // `<td>` can read the same `meta.numeric`/`meta.align` as the loaded
2213
+ // header/body cells — a loading table whose skeleton didn't mirror the
2214
+ // real alignment is exactly the column-shift-on-load bug
2215
+ // loading-states.md § "CLS / space reservation" warns about.
2216
+ const visibleColumns = table.getVisibleLeafColumns();
1096
2217
  return Array.from({ length: count }).map((_, i) => (
1097
2218
  <tr key={`skeleton-${i}`} aria-hidden="true" className={rowSeparationClass(i)}>
1098
- {Array.from({ length: colCount }).map((_, j) => (
1099
- <td key={j} className="px-3 py-2 align-middle">
2219
+ {hasGripColumn && (
2220
+ <td className="w-10 px-3 py-2 align-middle">
2221
+ <Skeleton className="size-4" />
2222
+ </td>
2223
+ )}
2224
+ {visibleColumns.map((column) => (
2225
+ <td
2226
+ key={column.id}
2227
+ className={cn(
2228
+ "px-3 py-2 align-middle",
2229
+ numericColumnClasses(column.columnDef.meta),
2230
+ columnDividers && COLUMN_DIVIDER_CLASS,
2231
+ )}
2232
+ >
1100
2233
  <Skeleton className="h-4 w-full" />
1101
2234
  </td>
1102
2235
  ))}
@@ -1111,7 +2244,10 @@ function DataTableInner<TData, TValue>(
1111
2244
  function renderEmptyBody() {
1112
2245
  return (
1113
2246
  <tr>
1114
- <td colSpan={colCount} className="h-24 px-3 text-center text-muted-foreground">
2247
+ <td
2248
+ colSpan={colCount + (hasGripColumn ? 1 : 0)}
2249
+ className="h-24 px-3 text-center text-muted-foreground"
2250
+ >
1115
2251
  {emptyMessage}
1116
2252
  </td>
1117
2253
  </tr>
@@ -1123,8 +2259,68 @@ function DataTableInner<TData, TValue>(
1123
2259
  if (showSkeletons) {
1124
2260
  return <tbody>{renderSkeletonBody(skeletonRowCount)}</tbody>;
1125
2261
  }
2262
+ if (showEmpty) {
2263
+ return <tbody>{renderEmptyBody()}</tbody>;
2264
+ }
2265
+ if (!rowReorderActive) {
2266
+ return <tbody>{rows.map((row, i) => renderRow(row, i))}</tbody>;
2267
+ }
1126
2268
 
1127
- return <tbody>{showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i))}</tbody>;
2269
+ // #13: `SortableContext` renders no DOM element of its own (a plain
2270
+ // context Provider), so nesting it around `<tbody>` here does not insert
2271
+ // anything between `<table>` and `<tbody>` — the real DOM stays valid.
2272
+ return (
2273
+ <SortableContext
2274
+ items={rows.map((r) => getReorderRowId(r))}
2275
+ strategy={verticalListSortingStrategy}
2276
+ >
2277
+ <tbody>
2278
+ {rows.map((row, i) => (
2279
+ <SortableDataRow
2280
+ key={getReorderRowId(row)}
2281
+ id={getReorderRowId(row)}
2282
+ attributesOverride={{
2283
+ // #98: dnd-kit's own `roleDescription: 'sortable'` default is
2284
+ // hardcoded English; override it with the localized value in
2285
+ // BOTH handle modes — `role` stays row-mode-only (see the
2286
+ // `attributesOverride` prop doc above).
2287
+ roleDescription: t("data.table.reorderRoleDescription"),
2288
+ ...(rowReorderHandle === "row" ? { role: "row" } : null),
2289
+ }}
2290
+ >
2291
+ {({ setNodeRef, setActivatorNodeRef, attributes, listeners, isDragging, style }) =>
2292
+ renderRow(
2293
+ row,
2294
+ i,
2295
+ {
2296
+ ref: setNodeRef,
2297
+ style,
2298
+ // `aria-pressed` is a `DraggableAttributes` field meant for a
2299
+ // real `<button>` activator; spread onto a `<tr role="row">`
2300
+ // (row-handle mode) it fails axe's `aria-allowed-attr` (that
2301
+ // ARIA state is not permitted on the `row` role), so strip it
2302
+ // here rather than exempt it downstream.
2303
+ ...(rowReorderHandle === "row"
2304
+ ? (() => {
2305
+ const { "aria-pressed": _ariaPressed, ...rowAttributes } = attributes;
2306
+ return { ...rowAttributes, ...listeners };
2307
+ })()
2308
+ : {}),
2309
+ } as React.HTMLAttributes<HTMLTableRowElement>,
2310
+ {
2311
+ isDragging,
2312
+ activator:
2313
+ rowReorderHandle === "cell"
2314
+ ? { setActivatorNodeRef, attributes, listeners }
2315
+ : undefined,
2316
+ },
2317
+ )
2318
+ }
2319
+ </SortableDataRow>
2320
+ ))}
2321
+ </tbody>
2322
+ </SortableContext>
2323
+ );
1128
2324
  }
1129
2325
 
1130
2326
  // ─── Virtualized tbody ────────────────────────────────────────────────────
@@ -1236,11 +2432,16 @@ function DataTableInner<TData, TValue>(
1236
2432
  <div
1237
2433
  ref={scrollRef}
1238
2434
  tabIndex={0}
1239
- // Names the focus stop (WCAG 4.1.2) without a landmark role a `role="region"`
1240
- // here would add a redundant landmark over the inner real <table>.
2435
+ // Names the focus stop (WCAG 4.1.2). A naming-capable role is required
2436
+ // for that name to compute at all `aria-label` on a plain `<div>`
2437
+ // (role `generic`) is not guaranteed to produce an accessible name.
2438
+ // `group`, not `region`: a landmark per table would be redundant over
2439
+ // the real <table> and collide under axe `landmark-unique` when two
2440
+ // tables share a page.
2441
+ role="group"
1241
2442
  aria-label={t("data.table.scrollRegion")}
1242
2443
  aria-busy={loading || undefined}
1243
- className="relative overflow-auto rounded-lg border bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2444
+ className="relative overflow-auto rounded-lg border bg-card focus-ring"
1244
2445
  style={{ maxHeight: maxBodyHeight, ...pinnedScrollPadding }}
1245
2446
  >
1246
2447
  {/* Loading overlay */}
@@ -1255,7 +2456,7 @@ function DataTableInner<TData, TValue>(
1255
2456
  className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
1256
2457
  >
1257
2458
  <Spinner aria-hidden="true" className="text-foreground" />
1258
- <span className="sr-only">Loading table data…</span>
2459
+ <span className="sr-only">{t("data.table.loading")}</span>
1259
2460
  </div>
1260
2461
  )}
1261
2462
  <table
@@ -1281,7 +2482,7 @@ function DataTableInner<TData, TValue>(
1281
2482
  // fades) and an INNER scrolling div (the focusable, `overflow-auto` scroll
1282
2483
  // region) so the edge-fade affordance can stay pinned to the visible edges
1283
2484
  // instead of scrolling away with the table content.
1284
- return (
2485
+ const nonVirtualizedContent = (
1285
2486
  <div ref={ref} className={cn("space-y-3", className)} {...rest}>
1286
2487
  {toolbar ? toolbar(table) : null}
1287
2488
  {/* Outer border is redundant (surface change) → plain border per #173 spec */}
@@ -1301,7 +2502,7 @@ function DataTableInner<TData, TValue>(
1301
2502
  className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
1302
2503
  >
1303
2504
  <Spinner aria-hidden="true" className="text-foreground" />
1304
- <span className="sr-only">Loading table data…</span>
2505
+ <span className="sr-only">{t("data.table.loading")}</span>
1305
2506
  </div>
1306
2507
  )}
1307
2508
  {/* The tab stop exists ONLY while the region measurably overflows: without
@@ -1309,16 +2510,21 @@ function DataTableInner<TData, TValue>(
1309
2510
  axe `scrollable-region-focusable`) — but adding it unconditionally would
1310
2511
  give every table that FITS a focus stop that does nothing and announces
1311
2512
  "scrollable" when it isn't. `aria-label` moves with it (WCAG 4.1.2:
1312
- a name for a stop that exists, none for one that doesn't). No
1313
- `role="region"` that would add a redundant landmark over the real
1314
- <table> inside it. */}
2513
+ a name for a stop that exists, none for one that doesn't) — and
2514
+ `role="group"` moves with BOTH of them: `aria-label` on a plain
2515
+ `<div>` (role `generic`) is not guaranteed to compute into an
2516
+ accessible name, so the stop needs a naming-capable role. `group`,
2517
+ never the `region` landmark: that would be redundant over the real
2518
+ <table> and collide (axe `landmark-unique`) with every other
2519
+ overflowing table on the page. */}
1315
2520
  <div
1316
2521
  ref={plainScrollRef}
1317
2522
  data-slot="data-table-scroll-region"
1318
2523
  tabIndex={scrollOverflows ? 0 : undefined}
2524
+ role={scrollOverflows ? "group" : undefined}
1319
2525
  aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
1320
2526
  onScroll={updateScrollAffordance}
1321
- className="overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
2527
+ className="overflow-auto rounded-lg focus-ring-inset"
1322
2528
  style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
1323
2529
  >
1324
2530
  <table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
@@ -1356,6 +2562,45 @@ function DataTableInner<TData, TValue>(
1356
2562
  {renderPagination()}
1357
2563
  </div>
1358
2564
  );
2565
+
2566
+ // #13: `DndContext` renders no wrapping DOM element around `children` either
2567
+ // — it composes `children` alongside its own hidden a11y nodes (the
2568
+ // screen-reader instructions, plus a `role="status"` `LiveRegion` that is
2569
+ // permanently silent — see `silentDragAnnouncements` above) as SIBLINGS.
2570
+ // Wrapping the whole component root here (rather than reaching inside the
2571
+ // `<table>`) is what keeps those hidden nodes out of the table's own DOM —
2572
+ // they land beside the table's outer `<div>`, never inside a
2573
+ // `<thead>`/`<tbody>`, which is the only place in HTML that would reject
2574
+ // them. DataTable's OWN `aria-live="polite"` region (`reorderLiveMessage`)
2575
+ // is a further sibling here for the same reason.
2576
+ if (!rowReorderActive) return nonVirtualizedContent;
2577
+ return (
2578
+ <DndContext
2579
+ sensors={reorderSensors}
2580
+ collisionDetection={closestCenter}
2581
+ onDragStart={handleRowDragStart}
2582
+ onDragOver={handleRowDragOver}
2583
+ onDragEnd={handleRowDragEnd}
2584
+ onDragCancel={handleRowDragCancel}
2585
+ accessibility={{
2586
+ announcements: silentDragAnnouncements,
2587
+ // #98: dnd-kit's own hidden keyboard-instructions node is hardcoded
2588
+ // English (`defaultScreenReaderInstructions`) unless overridden here.
2589
+ screenReaderInstructions: { draggable: t("data.table.reorderInstructions") },
2590
+ }}
2591
+ >
2592
+ {nonVirtualizedContent}
2593
+ <div
2594
+ role="status"
2595
+ aria-live="polite"
2596
+ aria-atomic="true"
2597
+ data-slot="data-table-reorder-live-region"
2598
+ className="sr-only"
2599
+ >
2600
+ {reorderLiveMessage}
2601
+ </div>
2602
+ </DndContext>
2603
+ );
1359
2604
  }
1360
2605
 
1361
2606
  // ─── Public export with forwardRef + generic cast ─────────────────────────────