@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.
@@ -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,48 @@ export interface DataTableProps<TData, TValue> extends Omit<
210
415
  */
211
416
  zebra?: boolean;
212
417
 
418
+ // ── Row drag-reorder (#13) ───────────────────────────────────────────────
419
+ /**
420
+ * Opt-in row drag-reorder. Off by default — an existing table renders
421
+ * byte-identical markup with no extra DOM per row until this is set.
422
+ * Fully controlled like every other slice: the component never mutates
423
+ * `data` itself, it only reports the move via `onRowReorder`; the caller
424
+ * re-orders `data` in response.
425
+ *
426
+ * Keyboard-operable out of the box (`@dnd-kit`'s default keyboard sensor):
427
+ * Space/Enter picks a row up, Arrow Up/Down moves it, Space/Enter drops it,
428
+ * Escape cancels. Every position change is announced through a live region
429
+ * (WCAG 4.1.3).
430
+ *
431
+ * Mutually exclusive with `enableRowVirtualization` — a windowed table
432
+ * can't keep dnd-kit's sortable list and a virtualizer in sync, so reorder
433
+ * is silently disabled (a dev warning fires) when both are set. Combining
434
+ * it with active `sorting` also fires a dev warning (both still work, but
435
+ * a sort re-orders the very rows a drag just moved, which reads as broken).
436
+ */
437
+ enableRowReorder?: boolean;
438
+ /**
439
+ * Fires when a row is dropped in a new position. `from`/`to` are indices
440
+ * into the **`data` array you passed in** — never into the sorted, filtered
441
+ * or paginated view the table renders — so they are safe to use directly
442
+ * with `arrayMove`/`slice`+`splice`/immer against your own `data`, unchanged
443
+ * by an active sort or by client-side pagination (the dragged row's true
444
+ * index in the full array, not its index on the current page). Under
445
+ * `manualPagination`, `data` IS the current page, so `from`/`to` are
446
+ * page-relative — reorder that page's own array with them. `row` is the
447
+ * moved record (`data[from]`).
448
+ */
449
+ onRowReorder?: (from: number, to: number, row: TData) => void;
450
+ /**
451
+ * Where the drag activator lives. `"cell"` (default) renders a dedicated
452
+ * grip-handle column so the rest of the row keeps its ordinary click/
453
+ * keyboard behavior untouched. `"row"` makes the whole row itself the drag
454
+ * activator (no extra column) — reach for this only when the row has no
455
+ * other primary interaction (e.g. no `onRowClick`), since a whole-row
456
+ * activator and a row click target the same surface.
457
+ */
458
+ rowReorderHandle?: "cell" | "row";
459
+
213
460
  /**
214
461
  * Fires when a row is activated (#337). Setting it adds ONE activation
215
462
  * target per row: a visually-hidden `<button>` rendered inside the row's
@@ -328,6 +575,194 @@ function unsizedColumnIds<TData, TValue>(defs: readonly ColumnDef<TData, TValue>
328
575
  return out;
329
576
  }
330
577
 
578
+ // ─── Column resizing (#12) ────────────────────────────────────────────────────
579
+
580
+ /**
581
+ * Explicit width/min/max triad for one column at its CURRENT size.
582
+ *
583
+ * The table is auto-layout (see the note on `pinnedCellGeometry` below), so
584
+ * without an explicit width an unpinned column is pure browser auto-layout —
585
+ * `column.getSize()` can change (via a drag or a keyboard resize) with
586
+ * nothing rendering differently. A pinned cell already gets this triad from
587
+ * `pinnedCellGeometry`'s own `style`; this is the same triad for the
588
+ * UNPINNED case, so every call site can compute it once and use it in both
589
+ * the pinned-or-not branches (`geometry?.style ?? resizeWidthStyle(size)`).
590
+ * Every call site gates this behind `enableColumnResizing`, so a table that
591
+ * doesn't opt in renders byte-identical markup to before this feature
592
+ * existed.
593
+ */
594
+ function resizeWidthStyle(size: number): React.CSSProperties {
595
+ return { width: size, minWidth: size, maxWidth: size };
596
+ }
597
+
598
+ // ─── Row-selection column (#11) ──────────────────────────────────────────────
599
+ //
600
+ // `flexRender` mounts a function `header`/`cell` as a real React component
601
+ // (`React.createElement(Comp, props)`, not a bare function call — see
602
+ // `@tanstack/react-table`'s `flexRender`), so these are ordinary components:
603
+ // hooks (`useLocale`) are safe inside them.
604
+
605
+ /**
606
+ * The row's own "primary identifier" — the first visible DATA column's value,
607
+ * skipping display columns that carry no `accessorKey`/`accessorFn` (e.g. a
608
+ * leading `createSelectionColumn()` checkbox, or a decorative avatar column).
609
+ * `column.accessorFn` is public TanStack API, populated for any
610
+ * `accessorKey`/`accessorFn` column and `undefined` for a pure display column
611
+ * (`core/column.ts`) — so this is a reliable "is this a data column" test.
612
+ * Shared by `rowActionName` (#337) and the selection column's per-row
613
+ * accessible name (#11 I4/I6), so a leading selection column can't silently
614
+ * degrade either one to its generic fallback.
615
+ */
616
+ function firstDataCellValue<TData>(row: Row<TData>): string | undefined {
617
+ for (const cell of row.getVisibleCells()) {
618
+ if (!cell.column.accessorFn) continue;
619
+ const value = cell.getValue();
620
+ if (typeof value === "string" && value.trim() !== "") return value;
621
+ if (typeof value === "number") return String(value);
622
+ }
623
+ return undefined;
624
+ }
625
+
626
+ /**
627
+ * Select-all header cell. Radix `Checkbox` renders a genuinely distinct
628
+ * `indeterminate` glyph + `aria-checked="mixed"` for a partial page
629
+ * selection (see `checkbox.tsx`), so the visual and the accessible state
630
+ * agree without any extra wiring here.
631
+ */
632
+ function SelectAllHeaderCell<TData>({ table }: { table: TanstackTable<TData> }) {
633
+ const { t } = useLocale();
634
+ const allSelected = table.getIsAllPageRowsSelected();
635
+ const someSelected = table.getIsSomePageRowsSelected();
636
+ return (
637
+ <Checkbox
638
+ data-slot="data-table-select-all"
639
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
640
+ onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
641
+ aria-label={t("data.table.selectAllRows")}
642
+ />
643
+ );
644
+ }
645
+
646
+ /**
647
+ * Per-row checkbox cell — disabled when `enableRowSelection` excludes the
648
+ * row. Names each checkbox from the row's own data (#11 I4) instead of the
649
+ * identical generic label every row previously shared, using the same
650
+ * "first data cell" lookup `rowActionName` (#337) already uses.
651
+ */
652
+ function SelectRowCell<TData>({ row }: { row: Row<TData> }) {
653
+ const { t } = useLocale();
654
+ const name = firstDataCellValue(row);
655
+ return (
656
+ <Checkbox
657
+ data-slot="data-table-select-cell"
658
+ checked={row.getIsSelected()}
659
+ disabled={!row.getCanSelect()}
660
+ onCheckedChange={(checked) => row.toggleSelected(checked === true)}
661
+ aria-label={name ? t("data.table.selectRowNamed", { name }) : t("data.table.selectRow")}
662
+ />
663
+ );
664
+ }
665
+
666
+ /**
667
+ * Ready-made checkbox selection column (#11): header select-all (with a real
668
+ * `indeterminate` state for a partial page selection) + a per-row checkbox,
669
+ * both built on `@elabs-ai/components-ui`'s `Checkbox` — never hand-roll one.
670
+ *
671
+ * Add it to `columns` and pair it with `rowSelection` / `onRowSelectionChange`
672
+ * (or leave both uncontrolled and read `table.getSelectedRowModel()` from a
673
+ * `toolbar` render-prop to build a bulk-action bar).
674
+ *
675
+ * Declares an explicit `size` (40px) so it plays nicely if a caller pins it —
676
+ * every pinned column must declare one (#333) — without the dev warning.
677
+ */
678
+ export function createSelectionColumn<TData>(): ColumnDef<TData> {
679
+ return {
680
+ id: "select",
681
+ size: 40,
682
+ enableSorting: false,
683
+ enableHiding: false,
684
+ header: ({ table }) =>
685
+ // #11 C1: `toggleAllPageRowsSelected` wipes-then-sets on every row when
686
+ // `enableMultiRowSelection` is off (TanStack's `mutateRowIsSelected`), so
687
+ // a select-all header under single-select leaves only the LAST row
688
+ // selected and pins the header at indeterminate forever. Suppress it.
689
+ table.options.enableMultiRowSelection === false ? null : (
690
+ <SelectAllHeaderCell table={table} />
691
+ ),
692
+ cell: ({ row }) => <SelectRowCell row={row} />,
693
+ };
694
+ }
695
+
696
+ // ─── Row drag-reorder (#13) ─────────────────────────────────────────────────
697
+
698
+ /** Render-prop payload `SortableDataRow` hands its child — the live dnd-kit
699
+ * registration for one row. */
700
+ interface SortableRowRenderArgs {
701
+ setNodeRef: (node: HTMLElement | null) => void;
702
+ setActivatorNodeRef: (node: HTMLElement | null) => void;
703
+ attributes: DraggableAttributes;
704
+ listeners: DraggableSyntheticListeners;
705
+ isDragging: boolean;
706
+ style: React.CSSProperties;
707
+ }
708
+
709
+ /**
710
+ * Per-row `@dnd-kit` registration, defined ONCE at module level.
711
+ *
712
+ * This must be a real component, not a hook call inlined into `rows.map()`
713
+ * (that would call `useSortable` a variable number of times across renders —
714
+ * the classic "hook in a loop" Rules-of-Hooks violation the moment the row
715
+ * count changes) and not a component DEFINED inside `DataTableInner`'s body
716
+ * either (a function created fresh every render gets a new `type` identity,
717
+ * so React would tear down and remount the whole row subtree, including
718
+ * dnd-kit's own internal drag state, on every re-render). A stable top-level
719
+ * component keyed by `id` gives every row its own persistent `useSortable`
720
+ * state via ordinary type+key reconciliation.
721
+ *
722
+ * `transition: null` is deliberate — dnd-kit's own transition is a raw
723
+ * inline `ms` duration, which would bypass the gated `duration-*`/`ease-*`
724
+ * utilities (quality-gates.md "Motion-tokened"). The moving row instead gets
725
+ * `transition-transform duration-base ease-standard motion-reduce:transition-none`
726
+ * as a class at the call site; only the live `transform` stays inline.
727
+ */
728
+ function SortableDataRow({
729
+ id,
730
+ disabled,
731
+ attributesOverride,
732
+ children,
733
+ }: {
734
+ id: string;
735
+ disabled?: boolean;
736
+ /**
737
+ * `rowReorderHandle: "row"` applies `attributes`/`listeners` straight to
738
+ * the `<tr>` (no separate activator element), so dnd-kit's DEFAULT
739
+ * `role="button"` would replace the table's own `role="row"` on that
740
+ * element — destroying its row semantics. Override the role in that mode
741
+ * only; `"cell"` mode leaves `role` unset because the grip `<button>` —
742
+ * not the `<tr>` — receives `attributes`/`listeners`. `roleDescription` is
743
+ * overridden in BOTH modes (#98) — it carries dnd-kit's localized
744
+ * `aria-roledescription`, which the activator needs regardless of which
745
+ * element is the activator.
746
+ */
747
+ attributesOverride?: { role?: string; roleDescription?: string; tabIndex?: number };
748
+ children: (args: SortableRowRenderArgs) => ReactNode;
749
+ }) {
750
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging } =
751
+ useSortable({ id, disabled, transition: null, attributes: attributesOverride });
752
+ return (
753
+ <>
754
+ {children({
755
+ setNodeRef,
756
+ setActivatorNodeRef,
757
+ attributes,
758
+ listeners,
759
+ isDragging,
760
+ style: { transform: CSS.Transform.toString(transform) },
761
+ })}
762
+ </>
763
+ );
764
+ }
765
+
331
766
  // ─── Component (inner, generic) ───────────────────────────────────────────────
332
767
 
333
768
  /**
@@ -368,6 +803,15 @@ function DataTableInner<TData, TValue>(
368
803
  onPaginationChange: onPaginationChangeProp,
369
804
  columnPinning: columnPinningProp,
370
805
  onColumnPinningChange: onColumnPinningChangeProp,
806
+ enableColumnResizing = false,
807
+ columnResizeMode = "onChange",
808
+ columnSizing: columnSizingProp,
809
+ onColumnSizingChange: onColumnSizingChangeProp,
810
+ rowSelection: rowSelectionProp,
811
+ onRowSelectionChange: onRowSelectionChangeProp,
812
+ enableRowSelection,
813
+ enableMultiRowSelection,
814
+ getRowId,
371
815
 
372
816
  // Saved views rehydration
373
817
  initialView,
@@ -391,6 +835,12 @@ function DataTableInner<TData, TValue>(
391
835
  maxBodyHeight = "32rem",
392
836
 
393
837
  zebra = true,
838
+
839
+ // Row drag-reorder (#13)
840
+ enableRowReorder = false,
841
+ onRowReorder,
842
+ rowReorderHandle = "cell",
843
+
394
844
  onRowClick,
395
845
  rowActionLabel,
396
846
  rowClassName,
@@ -403,7 +853,13 @@ function DataTableInner<TData, TValue>(
403
853
  ) {
404
854
  // Component microcopy goes through the locale seam (ADR 0017) — a screen-reader
405
855
  // user in a non-English locale has no workaround for a hardcoded accessible name.
406
- const { t } = useLocale();
856
+ // `dir` also drives column-resize direction below (#12 review, P1): the resize
857
+ // handle already sits at the column's logical `end` edge (`end-0`, which
858
+ // Tailwind's logical properties flip to the physical LEFT under RTL), so both
859
+ // TanStack's own pointer-drag math and the hand-rolled keyboard path must be
860
+ // told the active direction too, or dragging/pressing an arrow moves the width
861
+ // opposite the visible boundary.
862
+ const { t, dir, formatNumber } = useLocale();
407
863
 
408
864
  // ── Controlled/uncontrolled detection ────────────────────────────────────
409
865
  const isSortingControlled = sortingProp !== undefined;
@@ -412,6 +868,8 @@ function DataTableInner<TData, TValue>(
412
868
  const isPaginationControlled = paginationProp !== undefined;
413
869
  const isFilterControlled = globalFilterProp !== undefined;
414
870
  const isColumnPinningControlled = columnPinningProp !== undefined;
871
+ const isColumnSizingControlled = columnSizingProp !== undefined;
872
+ const isRowSelectionControlled = rowSelectionProp !== undefined;
415
873
 
416
874
  // ── Internal state (only drives a slice when uncontrolled) ───────────────
417
875
  const [internalSorting, setInternalSorting] = useState<SortingState>(
@@ -436,6 +894,12 @@ function DataTableInner<TData, TValue>(
436
894
  const [internalColumnPinning, setInternalColumnPinning] = useState<ColumnPinningState>(
437
895
  () => initialView?.columnPinning ?? { left: [], right: [] },
438
896
  );
897
+ const [internalColumnSizing, setInternalColumnSizing] = useState<ColumnSizingState>(
898
+ () => initialView?.columnSizing ?? {},
899
+ );
900
+ const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>(
901
+ () => initialView?.rowSelection ?? {},
902
+ );
439
903
 
440
904
  // ── Resolved state (controlled wins over internal) ───────────────────────
441
905
  const sorting = isSortingControlled ? sortingProp : internalSorting;
@@ -446,6 +910,8 @@ function DataTableInner<TData, TValue>(
446
910
  const pagination = isPaginationControlled ? paginationProp : internalPagination;
447
911
  const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
448
912
  const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
913
+ const columnSizing = isColumnSizingControlled ? columnSizingProp : internalColumnSizing;
914
+ const rowSelection = isRowSelectionControlled ? rowSelectionProp : internalRowSelection;
449
915
 
450
916
  // ── Refs for post-change server callback ─────────────────────────────────
451
917
  // We need the current values of ALL slices when any one fires; use refs to
@@ -462,6 +928,10 @@ function DataTableInner<TData, TValue>(
462
928
  columnVisibilityRef.current = columnVisibility;
463
929
  const columnPinningRef = useRef(columnPinning);
464
930
  columnPinningRef.current = columnPinning;
931
+ const columnSizingRef = useRef(columnSizing);
932
+ columnSizingRef.current = columnSizing;
933
+ const rowSelectionRef = useRef(rowSelection);
934
+ rowSelectionRef.current = rowSelection;
465
935
 
466
936
  // ── Dev-only guard: manualPagination needs a total to compute page count ──
467
937
  // Without `rowCount` (or `pageCount`), TanStack's `getPageCount()` falls back
@@ -486,6 +956,120 @@ function DataTableInner<TData, TValue>(
486
956
  }
487
957
  }, [manualPagination, rowCount, pageCount]);
488
958
 
959
+ // ── Dev-only guard: manualPagination + rowSelection with no getRowId ──────
960
+ // Under `manualPagination` each page IS a fresh `data` array, so TanStack's
961
+ // default index-based row id restarts at `0` on every page — a selection
962
+ // made on page 1's row 0 can silently apply to page 2's row 0 too (#11 I3).
963
+ // Warn once per mount so this footgun is diagnosable instead of silent (same
964
+ // idiom as the #227 warning above). Heuristic, not full usage tracing: fires
965
+ // whenever selection LOOKS wired up (controlled, or a change handler was
966
+ // passed) — it cannot see an uncontrolled table that never renders a
967
+ // selection column at all.
968
+ const warnedManualSelectionRef = useRef(false);
969
+ useEffect(() => {
970
+ if (
971
+ process.env.NODE_ENV !== "production" &&
972
+ manualPagination &&
973
+ getRowId === undefined &&
974
+ (isRowSelectionControlled || onRowSelectionChangeProp !== undefined) &&
975
+ !warnedManualSelectionRef.current
976
+ ) {
977
+ warnedManualSelectionRef.current = true;
978
+ console.warn(
979
+ "[DataTable] `rowSelection` is wired up under `manualPagination` with no `getRowId` " +
980
+ "— each page is a fresh `data` array, so the default index-based id restarts at " +
981
+ '"0" per page and a selection made on one page can silently apply to a different ' +
982
+ "record on the next. Pass `getRowId` so selection is keyed to a stable identity " +
983
+ "instead of position.",
984
+ );
985
+ }
986
+ }, [manualPagination, getRowId, isRowSelectionControlled, onRowSelectionChangeProp]);
987
+
988
+ // ── Dev-only guard: enableRowReorder + active sorting (#13) ───────────────
989
+ // Both keep working — this doesn't disable anything — but a sort re-orders
990
+ // the very rows a drag just moved, which reads as broken rather than merely
991
+ // confusing. Warn once per mount, same idiom as the two guards above.
992
+ const warnedReorderSortingRef = useRef(false);
993
+ useEffect(() => {
994
+ if (
995
+ process.env.NODE_ENV !== "production" &&
996
+ enableRowReorder &&
997
+ sorting.length > 0 &&
998
+ !warnedReorderSortingRef.current
999
+ ) {
1000
+ warnedReorderSortingRef.current = true;
1001
+ console.warn(
1002
+ "[DataTable] `enableRowReorder` is set while a column is sorted — the sort will " +
1003
+ "keep re-ordering rows out from under a manual drag. Clear `sorting` (or avoid " +
1004
+ "enabling both at once) so a drag's new order stays stable.",
1005
+ );
1006
+ }
1007
+ }, [enableRowReorder, sorting.length]);
1008
+
1009
+ // ── Dev-only guard: enableRowReorder + enableRowVirtualization (#13) ──────
1010
+ // A windowed table can't keep dnd-kit's sortable list in sync with a
1011
+ // virtualizer that only mounts a subset of rows, so the two are mutually
1012
+ // exclusive — virtualization wins (same precedent as enablePagination vs.
1013
+ // enableRowVirtualization) and reorder is silently disabled below
1014
+ // (`rowReorderActive`). This warning is the diagnostic for why.
1015
+ const warnedReorderVirtualizedRef = useRef(false);
1016
+ useEffect(() => {
1017
+ if (
1018
+ process.env.NODE_ENV !== "production" &&
1019
+ enableRowReorder &&
1020
+ enableRowVirtualization &&
1021
+ !warnedReorderVirtualizedRef.current
1022
+ ) {
1023
+ warnedReorderVirtualizedRef.current = true;
1024
+ console.warn(
1025
+ "[DataTable] `enableRowReorder` has no effect while `enableRowVirtualization` is " +
1026
+ "set — the two are mutually exclusive. Virtualization wins; row reorder is disabled.",
1027
+ );
1028
+ }
1029
+ }, [enableRowReorder, enableRowVirtualization]);
1030
+
1031
+ // Only wired up in the non-virtualized body — see the warning above.
1032
+ const rowReorderActive = enableRowReorder && !enableRowVirtualization;
1033
+ const hasGripColumn = rowReorderActive && rowReorderHandle === "cell";
1034
+
1035
+ const reorderSensors = useSensors(
1036
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
1037
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1038
+ );
1039
+ // Backing store for `getReorderRowId` (defined below, once `rows` is in
1040
+ // scope) — see its own doc comment for why a WeakMap keyed by row object
1041
+ // reference is the round-1 fix for findings 1 & 3.
1042
+ const reorderIdentityMapRef = useRef<WeakMap<object, string>>(new WeakMap());
1043
+ const reorderIdentityCounterRef = useRef(0);
1044
+ // Positions in `data` whose record REPEATS an object reference that already
1045
+ // appeared earlier in the array — 2nd and later occurrences only (round-2
1046
+ // finding 6). `getReorderRowId` below keys its identity on the record's own
1047
+ // object reference, which is exactly what makes an id survive the array
1048
+ // REPLACEMENT every reorder idiom performs; the cost is that a record the
1049
+ // caller listed twice IS one reference, so both rows would be handed one id
1050
+ // — one React key, one dnd-kit registration, and a drop that can only ever
1051
+ // name the first occurrence. The positions listed here get their own data
1052
+ // index folded into the id so the occurrences stay separately addressable.
1053
+ // Only the repeats are suffixed, so a table with no repeated record keeps
1054
+ // byte-identical ids (and with them the round-1 focus restore).
1055
+ const reorderRepeatedPositions = useMemo(() => {
1056
+ const repeats = new Set<number>();
1057
+ if (!rowReorderActive) return repeats;
1058
+ const seen = new Set<unknown>();
1059
+ data.forEach((record, index) => {
1060
+ if (record === null || typeof record !== "object") return;
1061
+ if (seen.has(record)) repeats.add(index);
1062
+ else seen.add(record);
1063
+ });
1064
+ return repeats;
1065
+ }, [data, rowReorderActive]);
1066
+ // The component's OWN `aria-live="polite"` announcer state — round-1
1067
+ // finding 4 (dnd-kit's built-in region is hardcoded `assertive` with no
1068
+ // override). `reorderLastAnnouncedPositionRef` de-dupes a same-position
1069
+ // re-fire (the pickup self-collision, a no-op arrow press at a boundary).
1070
+ const [reorderLiveMessage, setReorderLiveMessage] = useState("");
1071
+ const reorderLastAnnouncedPositionRef = useRef<number | null>(null);
1072
+
489
1073
  /** Fire onServerChange with the LATEST slice values (post-update). */
490
1074
  function fireServerChange(overrides: Partial<DataTableServerArgs> = {}) {
491
1075
  if (!onServerChange) return;
@@ -526,6 +1110,16 @@ function DataTableInner<TData, TValue>(
526
1110
  ): ColumnPinningState {
527
1111
  return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
528
1112
  }
1113
+ function resolveColumnSizing(
1114
+ updater: Parameters<OnChangeFn<ColumnSizingState>>[0],
1115
+ ): ColumnSizingState {
1116
+ return typeof updater === "function" ? updater(columnSizingRef.current) : updater;
1117
+ }
1118
+ function resolveRowSelection(
1119
+ updater: Parameters<OnChangeFn<RowSelectionState>>[0],
1120
+ ): RowSelectionState {
1121
+ return typeof updater === "function" ? updater(rowSelectionRef.current) : updater;
1122
+ }
529
1123
 
530
1124
  // ── Row models — omit client model for manual slices ─────────────────────
531
1125
  const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
@@ -542,7 +1136,16 @@ function DataTableInner<TData, TValue>(
542
1136
  const table = useReactTable({
543
1137
  data,
544
1138
  columns,
545
- state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
1139
+ state: {
1140
+ sorting,
1141
+ columnVisibility,
1142
+ columnFilters,
1143
+ globalFilter,
1144
+ pagination,
1145
+ columnPinning,
1146
+ columnSizing,
1147
+ rowSelection,
1148
+ },
546
1149
 
547
1150
  // Sorting
548
1151
  onSortingChange: (updater) => {
@@ -605,6 +1208,40 @@ function DataTableInner<TData, TValue>(
605
1208
  onColumnPinningChangeProp?.(updater);
606
1209
  },
607
1210
 
1211
+ // Column resizing (#12) — a LAYOUT slice, like column pinning: a column's
1212
+ // width changes nothing the server would need to re-query, so this never
1213
+ // fires onServerChange either. Routed through by BOTH the pointer path
1214
+ // (TanStack's own `header.getResizeHandler()`, wired below) and the
1215
+ // keyboard path (`handleResizeKeyDown`, via `table.setColumnSizing`) so
1216
+ // the two input modes can never diverge in controlled/uncontrolled
1217
+ // behaviour.
1218
+ columnResizeMode,
1219
+ // RTL fix (#12 review, P1): TanStack's pointer-drag math hardcodes LTR
1220
+ // unless told otherwise — `deltaDirection = columnResizeDirection ===
1221
+ // 'rtl' ? -1 : 1` internally — so under `dir="rtl"` (the resize handle's
1222
+ // own edge already flips via `end-0`, see the `useLocale()` call above)
1223
+ // dragging would otherwise move the column's width opposite the visible
1224
+ // boundary. `handleResizeKeyDown` below mirrors this for the keyboard path.
1225
+ columnResizeDirection: dir,
1226
+ enableColumnResizing,
1227
+ onColumnSizingChange: (updater) => {
1228
+ const next = resolveColumnSizing(updater);
1229
+ if (!isColumnSizingControlled) setInternalColumnSizing(next);
1230
+ onColumnSizingChangeProp?.(updater);
1231
+ },
1232
+
1233
+ // Row selection (#11) — also a LAYOUT/UI slice, so it never fires
1234
+ // onServerChange: which rows are checked changes nothing the server
1235
+ // would need to re-query.
1236
+ onRowSelectionChange: (updater) => {
1237
+ const next = resolveRowSelection(updater);
1238
+ if (!isRowSelectionControlled) setInternalRowSelection(next);
1239
+ onRowSelectionChangeProp?.(updater);
1240
+ },
1241
+ enableRowSelection,
1242
+ enableMultiRowSelection,
1243
+ getRowId,
1244
+
608
1245
  getCoreRowModel: getCoreRowModel(),
609
1246
  ...sortedRowModel,
610
1247
  ...filteredRowModel,
@@ -635,6 +1272,185 @@ function DataTableInner<TData, TValue>(
635
1272
  const headerRowCount = table.getHeaderGroups().length;
636
1273
  const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
637
1274
 
1275
+ // ── Row drag-reorder (#13) ────────────────────────────────────────────────
1276
+ // `rowActionName` (defined below, but hoisted as a function declaration) is
1277
+ // the SAME row-naming lookup `onRowClick`'s hidden button uses (#337) —
1278
+ // reusing it means a reorder announcement names a row exactly the way its
1279
+ // click target already does, rather than inventing a second convention.
1280
+ function reorderRowName(id: string): string {
1281
+ const row = rows.find((r) => getReorderRowId(r) === id);
1282
+ return row ? rowActionName(row) : id;
1283
+ }
1284
+ function reorderPosition(id: string): number {
1285
+ return rows.findIndex((r) => getReorderRowId(r) === id) + 1;
1286
+ }
1287
+
1288
+ // ── Stable identity for drag reconciliation (round-1 fix, findings 1 & 3) ──
1289
+ // `getRowId`'s own doc comment above states TanStack's fallback: default row
1290
+ // ids are assigned ONCE per row object when the core row model is built from
1291
+ // the current `data` ARRAY REFERENCE, then carried by reference through
1292
+ // sort/filter — but a `data` array REPLACEMENT (exactly what every
1293
+ // `onRowReorder` consumer does: `arrayMove`/`slice`+`splice`/immer all
1294
+ // return a new array) rebuilds the core row model and reassigns ids by
1295
+ // POSITION IN THE NEW ARRAY. So the id that used to denote "the row now at
1296
+ // index 1" keeps denoting index 1 even though a different record moved
1297
+ // there — which is what let a keyboard drop leave focus on the wrong row
1298
+ // (a different record now sits at the id the focus restore targets).
1299
+ // Requiring every consumer to hand-roll `getRowId` would leave the DEFAULT
1300
+ // configuration broken, so when the caller hasn't supplied one, mint an id
1301
+ // keyed by the row's own OBJECT REFERENCE (`row.original`) in a `WeakMap` —
1302
+ // unlike TanStack's default, this id follows the object wherever it lands
1303
+ // in a new array, because every reorder idiom MOVES the element reference,
1304
+ // it never clones it. When `getRowId` IS supplied it is already exactly
1305
+ // this kind of identity, so it's reused as-is instead of minting a second,
1306
+ // divergent id namespace.
1307
+ function getReorderRowId(row: Row<TData>): string {
1308
+ if (getRowId) return row.id;
1309
+ const original: unknown = row.original;
1310
+ if (original !== null && typeof original === "object") {
1311
+ const map = reorderIdentityMapRef.current;
1312
+ let id = map.get(original);
1313
+ if (id === undefined) {
1314
+ id = `__reorder-${reorderIdentityCounterRef.current++}`;
1315
+ map.set(original, id);
1316
+ }
1317
+ // A repeated record shares ONE object reference, so the id minted above
1318
+ // is by construction identical for both of its rows — round-2 finding
1319
+ // 6. Fold the data position into the repeats so each occupant is its
1320
+ // own draggable. Two identical records are interchangeable to the user,
1321
+ // so the weaker cross-replacement stability of a suffixed id costs
1322
+ // nothing the first-occurrence rule doesn't already give back.
1323
+ return reorderRepeatedPositions.has(row.index) ? `${id}__${row.index}` : id;
1324
+ }
1325
+ // Primitive `TData` (rare) has no object reference to key off — same
1326
+ // documented limitation `getRowId`'s own comment already carries for
1327
+ // TanStack's own default identity.
1328
+ return row.id;
1329
+ }
1330
+
1331
+ // dnd-kit's own `Accessibility` component's `LiveRegion` hardcodes
1332
+ // `aria-live="assertive"` with no way to override it from `DndContext`
1333
+ // (`@dnd-kit/accessibility` 3.1.1 accepts an `ariaLiveType` prop on
1334
+ // `LiveRegion` itself, but nothing forwards one through `accessibility`) —
1335
+ // round-1 finding 4. `.claude/rules/accessibility.md` reserves assertive
1336
+ // for terminal errors (`role="alert"`); a sortable list's own position
1337
+ // updates are `polite` status. So dnd-kit's built-in announcer is silenced
1338
+ // below (every callback returns `undefined`, which `useAnnouncement`
1339
+ // treats as "no update" — the region stays permanently empty and never
1340
+ // fires) and DataTable renders its OWN `aria-live="polite"` region
1341
+ // (`reorderLiveMessage`, wired to the `data-table-reorder-live-region`
1342
+ // node near the bottom of this function) from the `onDragStart`/
1343
+ // `onDragOver`/`onDragEnd`/`onDragCancel` handlers below.
1344
+ const silentDragAnnouncements: Announcements = {
1345
+ onDragStart: () => undefined,
1346
+ onDragOver: () => undefined,
1347
+ onDragEnd: () => undefined,
1348
+ onDragCancel: () => undefined,
1349
+ };
1350
+
1351
+ /**
1352
+ * Pickup always announces — it's the start of a new, meaningful gesture.
1353
+ * Seeding `reorderLastAnnouncedPositionRef` with the row's OWN starting
1354
+ * position (not `null`) is what suppresses dnd-kit's immediate self-
1355
+ * collision `onDragOver` (over === active, at the same position) that
1356
+ * otherwise fires in the same tick and would stomp this message before it
1357
+ * is ever observable (WCAG 4.1.3 needs it heard, not just rendered).
1358
+ */
1359
+ function handleRowDragStart(event: DragStartEvent) {
1360
+ const activeRowId = String(event.active.id);
1361
+ reorderLastAnnouncedPositionRef.current = reorderPosition(activeRowId);
1362
+ setReorderLiveMessage(t("data.table.reorderPickedUp", { name: reorderRowName(activeRowId) }));
1363
+ }
1364
+
1365
+ /**
1366
+ * Announces a real position change only — round-1 finding 4 measured 4
1367
+ * announcements for a 2-step move, one of them a same-position self-
1368
+ * collision that buried the "picked up" message. De-duping on the actual
1369
+ * computed position (not on the raw event) means a screen reader hears one
1370
+ * `polite` (queued, non-interrupting) announcement per genuine move, not
1371
+ * one per keystroke.
1372
+ */
1373
+ function handleRowDragOver(event: DragOverEvent) {
1374
+ const { active, over } = event;
1375
+ if (!over) return;
1376
+ const position = reorderPosition(String(over.id));
1377
+ if (position === reorderLastAnnouncedPositionRef.current) return;
1378
+ reorderLastAnnouncedPositionRef.current = position;
1379
+ setReorderLiveMessage(
1380
+ t("data.table.reorderMoved", {
1381
+ name: reorderRowName(String(active.id)),
1382
+ position,
1383
+ total: rows.length,
1384
+ }),
1385
+ );
1386
+ }
1387
+
1388
+ function handleRowDragCancel(event: DragCancelEvent) {
1389
+ const activeRowId = String(event.active.id);
1390
+ setReorderLiveMessage(
1391
+ t("data.table.reorderCancelled", {
1392
+ name: reorderRowName(activeRowId),
1393
+ position: reorderPosition(activeRowId),
1394
+ total: rows.length,
1395
+ }),
1396
+ );
1397
+ reorderLastAnnouncedPositionRef.current = null;
1398
+ }
1399
+
1400
+ /**
1401
+ * The component never mutates `data` itself (D5 — presentation layer, not
1402
+ * an SDK): it only reports the move, the same "controlled slice" contract
1403
+ * every other DataTable feature follows. A no-op drop (dropped on itself,
1404
+ * or outside any droppable) fires nothing on the data callback, but still
1405
+ * announces (matching the "dropped back where it started" reality).
1406
+ *
1407
+ * `from`/`to` resolve against the ORIGINAL `data` array the caller passed
1408
+ * in, never against the sorted/paginated VIEW (`rows`) — round-1 finding 1.
1409
+ * Reporting `rows.findIndex(...)` positions meant a caller doing
1410
+ * `arrayMove(data, from, to)` (the idiom both shipped stories use) silently
1411
+ * moved the WRONG records whenever an active sort or a client-side page
1412
+ * had changed which record sat at which view position — measured: a
1413
+ * paginated drag on page 2 reported `(0, 1, …)`, corrupting `data[0]`/
1414
+ * `data[1]` on page 1. Resolving against `data` itself makes the contract
1415
+ * "indices into the `data` you gave me" — correct under any sort/filter,
1416
+ * correct under client-side pagination (the dragged record's true index in
1417
+ * the full array), and correct under `manualPagination` too (there `data`
1418
+ * IS the current page, so `from`/`to` are page-relative, which is exactly
1419
+ * what a caller reordering that page's own array needs).
1420
+ */
1421
+ function handleRowDragEnd(event: DragEndEvent) {
1422
+ const { active, over } = event;
1423
+ const activeRowId = String(active.id);
1424
+ setReorderLiveMessage(
1425
+ t("data.table.reorderDropped", {
1426
+ name: reorderRowName(activeRowId),
1427
+ position: reorderPosition(String(over ? over.id : active.id)),
1428
+ total: rows.length,
1429
+ }),
1430
+ );
1431
+ reorderLastAnnouncedPositionRef.current = null;
1432
+
1433
+ if (!over || active.id === over.id) return;
1434
+ const movedRow = rows.find((r) => getReorderRowId(r) === activeRowId);
1435
+ const targetRow = rows.find((r) => getReorderRowId(r) === String(over.id));
1436
+ if (!movedRow || !targetRow) return;
1437
+ // Round-2 finding 6: this used to build a `Map` keyed by `row.original`
1438
+ // and read `from`/`to` out of it. A `data` array that repeats a record —
1439
+ // the same object reference, or the same primitive, at two positions —
1440
+ // can only occupy ONE slot in such a map, so the later occurrence was
1441
+ // reported as the earlier one and the documented `arrayMove(data, from,
1442
+ // to)` idiom moved a row the user never dragged, silently. `Row.index` is
1443
+ // the position TanStack already assigned this row when it built the core
1444
+ // row model FROM `data`, carried by reference through sort/filter/
1445
+ // pagination (the same property the round-1 fix above relies on) — so it
1446
+ // keeps the "indices into the `data` you gave me" contract without the
1447
+ // value-equality lookup that collapsed the repeats.
1448
+ const from = movedRow.index;
1449
+ const to = targetRow.index;
1450
+ if (from < 0 || from >= data.length || to < 0 || to >= data.length) return;
1451
+ onRowReorder?.(from, to, movedRow.original);
1452
+ }
1453
+
638
1454
  // ── Pinning (#333) ────────────────────────────────────────────────────────
639
1455
  // Are there any pinned columns at all? Everything pinning-related is gated on
640
1456
  // this so a table with no pinning renders byte-identical markup to before.
@@ -736,7 +1552,7 @@ function DataTableInner<TData, TValue>(
736
1552
  // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
737
1553
  // seam pixel read `143,143,143` (light `--border-strong`) at
738
1554
  // 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
1555
+ // scrolled, in every theme and on both edges. So the one cue vanished
740
1556
  // exactly when the freeze was doing something. The `::after` lives in the
741
1557
  // sticky cell's own stacking context, so it moves with it.
742
1558
  edgeClass:
@@ -750,6 +1566,76 @@ function DataTableInner<TData, TValue>(
750
1566
  };
751
1567
  }
752
1568
 
1569
+ // ── Column resizing keyboard path (#12) ───────────────────────────────────
1570
+ // TanStack's own `header.getResizeHandler()` is pointer/touch-only — no
1571
+ // keyboard path exists in the library — so the WAI-ARIA separator-as-slider
1572
+ // practice (drag handle operable via ArrowLeft/ArrowRight when focused)
1573
+ // needs one small hand-rolled step. It goes through `table.setColumnSizing`
1574
+ // (`table.setColumnSizing = updater => table.options.onColumnSizingChange
1575
+ // ?.(updater)`, TanStack's own `ColumnSizing` feature), which is the SAME
1576
+ // `onColumnSizingChange` handler passed to `useReactTable` above — so
1577
+ // keyboard and pointer resizing share one controlled/uncontrolled code path
1578
+ // and can never diverge in behaviour.
1579
+ const RESIZE_STEP = 10;
1580
+ // ARIA fallback ceiling for the resize separator's `aria-valuemax` when the
1581
+ // column declares no explicit `maxSize` — a `ColumnDef` with no `maxSize`
1582
+ // resolves through TanStack's own default to `Number.MAX_SAFE_INTEGER`,
1583
+ // which is not a value any AT should announce, so the header below omits
1584
+ // `aria-valuemax` entirely in that case. Per the WAI-ARIA separator-as-
1585
+ // widget pattern, an ELEMENT WITH NO `aria-valuemax` is read with an
1586
+ // IMPLICIT default of 100 — so a column at its ordinary starting width
1587
+ // (150) already announces as "150 of 100", out of its own stated range
1588
+ // (#12 review, P2). `Math.max` with the live size at the call site below
1589
+ // keeps this always containing the current value: a column dragged past
1590
+ // this floor simply raises its own announced ceiling instead of going out
1591
+ // of range again.
1592
+ const RESIZE_UNBOUNDED_ARIA_MAX = 2000;
1593
+ function handleResizeKeyDown(event: React.KeyboardEvent, column: Column<TData, unknown>) {
1594
+ let delta = 0;
1595
+ if (event.key === "ArrowRight") delta = RESIZE_STEP;
1596
+ else if (event.key === "ArrowLeft") delta = -RESIZE_STEP;
1597
+ else return;
1598
+ event.preventDefault();
1599
+ // Mirror TanStack's own `columnResizeDirection` reversal (passed to
1600
+ // `useReactTable` above) for the keyboard path: the handle sits at the
1601
+ // column's logical `end` edge, which `end-0` renders on the physical
1602
+ // LEFT under `dir="rtl"` — so ArrowRight (physical right, toward the
1603
+ // column's own body) must SHRINK the column and ArrowLeft must GROW it,
1604
+ // the mirror image of LTR. Without this the keyboard path would diverge
1605
+ // from the now-direction-aware pointer path.
1606
+ if (dir === "rtl") delta = -delta;
1607
+ const minSize = column.columnDef.minSize ?? 20;
1608
+ const maxSize = column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
1609
+ const nextSize = Math.min(maxSize, Math.max(minSize, column.getSize() + delta));
1610
+ table.setColumnSizing((old) => ({ ...old, [column.id]: nextSize }));
1611
+ }
1612
+
1613
+ // #51 — double-click resets a resize handle's column back to its declared
1614
+ // `ColumnDef.size`, falling back to TanStack's own default (150, the same
1615
+ // fallback idiom as `minSize ?? 20`/`maxSize ?? MAX_SAFE_INTEGER` above) when
1616
+ // the author left it unset — by REMOVING any explicit `columnSizing` entry
1617
+ // for the column, not by writing the size back in as a literal (PR #81
1618
+ // review, "Remove the sizing override when resetting a column"). `columnSizing`
1619
+ // only ever carries EXPLICIT per-column overrides; a column absent from it
1620
+ // always tracks its live `ColumnDef.size` (or the 150 default). Writing the
1621
+ // CURRENT declared size back in as a value looks identical today but turns
1622
+ // the default into a permanent override: if the `columns` prop later
1623
+ // changes this column's authored `size` (e.g. switching table
1624
+ // configurations), a column that was never resized follows the new
1625
+ // definition for free, while a double-click-reset column would stay pinned
1626
+ // to the OLD number forever. Deleting the entry keeps it dynamic, exactly
1627
+ // like a column that was never touched. Still goes through the SAME
1628
+ // `table.setColumnSizing` dispatch path as `handleResizeKeyDown` — never
1629
+ // `column.resetSize()` — so a controlled `columnSizing` consumer observes
1630
+ // the reset via `onColumnSizingChange` exactly like every other resize.
1631
+ function handleResizeDoubleClick(column: Column<TData, unknown>) {
1632
+ table.setColumnSizing((old) => {
1633
+ if (!(column.id in old)) return old;
1634
+ const { [column.id]: _removed, ...rest } = old;
1635
+ return rest;
1636
+ });
1637
+ }
1638
+
753
1639
  // ── Scroll container ref for virtualizer ─────────────────────────────────
754
1640
  const scrollRef = useRef<HTMLDivElement>(null);
755
1641
 
@@ -839,6 +1725,11 @@ function DataTableInner<TData, TValue>(
839
1725
  >
840
1726
  {table.getHeaderGroups().map((headerGroup, groupIndex) => (
841
1727
  <tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
1728
+ {hasGripColumn && (
1729
+ <th key="__reorder" scope="col" className="h-10 w-10 px-3 align-middle">
1730
+ <span className="sr-only">{t("data.table.reorderColumnHeader")}</span>
1731
+ </th>
1732
+ )}
842
1733
  {headerGroup.headers.map((header) => {
843
1734
  const geometry = pinnedCellGeometry(header.column);
844
1735
  const canSort = header.column.getCanSort();
@@ -853,6 +1744,15 @@ function DataTableInner<TData, TValue>(
853
1744
  sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
854
1745
  const SortIcon =
855
1746
  sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
1747
+ // #12: every column gets the same explicit width triad a pinned
1748
+ // column already has, gated behind `enableColumnResizing` so a
1749
+ // table that doesn't opt in stays byte-identical to before.
1750
+ const resizeStyle = enableColumnResizing
1751
+ ? resizeWidthStyle(header.getSize())
1752
+ : undefined;
1753
+ const canResize =
1754
+ enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
1755
+ const resizeMax = header.column.columnDef.maxSize;
856
1756
  return (
857
1757
  <th
858
1758
  key={header.id}
@@ -867,9 +1767,24 @@ function DataTableInner<TData, TValue>(
867
1767
  : undefined
868
1768
  }
869
1769
  data-pinned={geometry?.pinned ?? undefined}
870
- style={geometry?.style}
1770
+ style={geometry?.style ?? resizeStyle}
871
1771
  className={cn(
1772
+ // Same `px-3` the body `<td>` uses (below) — deliberately
1773
+ // NOT split into `ps-3`/`pe-3` for a resize-handle
1774
+ // override (round-1 briefly did this, see the round-2
1775
+ // note on `numericColumnClasses`): the header's padding
1776
+ // must stay byte-identical to the body's so an
1777
+ // end-aligned numeric column's header lines up with its
1778
+ // own values.
872
1779
  "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
1780
+ // #69: a numeric column's `meta` overrides the default
1781
+ // `text-start` — placed right after the base string so
1782
+ // tailwind-merge lets it win over that default.
1783
+ numericColumnClasses(header.column.columnDef.meta),
1784
+ // `sticky`/pinned already establishes a positioning context
1785
+ // for the resize handle's `absolute`; an unpinned resizable
1786
+ // header needs its own.
1787
+ !geometry && canResize && "relative",
873
1788
  // A pinned HEADER cell is the corner where both freezes meet,
874
1789
  // so it stacks above the sticky header row (z-20) which is
875
1790
  // above the pinned body cells (z-10). It needs an OPAQUE
@@ -903,7 +1818,31 @@ function DataTableInner<TData, TValue>(
903
1818
  type="button"
904
1819
  onClick={header.column.getToggleSortingHandler()}
905
1820
  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"
1821
+ // `relative z-10` (round-2 fix, #82 follow-up replaces
1822
+ // round-1's padding-based clearance, see the note on
1823
+ // `numericColumnClasses`): on a resizable column the
1824
+ // resize handle below is `absolute`, and CSS painting
1825
+ // order always puts a positioned descendant above
1826
+ // non-positioned in-flow content in the SAME stacking
1827
+ // context, regardless of DOM order — so without this,
1828
+ // the handle's 24px hit box would win every hit-test
1829
+ // where it overlaps this button's own trailing edge
1830
+ // (measured: a 12px overlap on an end-aligned
1831
+ // sortable+resizable column) no matter which element
1832
+ // renders first in markup. Giving the button its own
1833
+ // explicit positive z-index (not just `relative`, which
1834
+ // alone would still lose — see the code comment on
1835
+ // `numericColumnClasses` above) promotes it into a
1836
+ // later, higher-stacked paint step than the handle's
1837
+ // implicit `z-index: auto`, so the button wins the
1838
+ // overlap purely at the hit-test/paint layer — the
1839
+ // header's padding, and therefore its alignment with
1840
+ // the body `<td>`, never has to move. The handle's own
1841
+ // visible drag affordance (the `after:` seam, 0-8px
1842
+ // from the cell's trailing edge) sits entirely outside
1843
+ // this button's box (which ends at the same 12px inset
1844
+ // as the body), so dragging is unaffected.
1845
+ className="relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring"
907
1846
  >
908
1847
  {flexRender(header.column.columnDef.header, header.getContext())}
909
1848
  <SortIcon
@@ -914,6 +1853,106 @@ function DataTableInner<TData, TValue>(
914
1853
  ) : (
915
1854
  flexRender(header.column.columnDef.header, header.getContext())
916
1855
  )}
1856
+ {canResize && (
1857
+ <div
1858
+ role="separator"
1859
+ aria-orientation="vertical"
1860
+ aria-valuenow={Math.round(header.getSize())}
1861
+ aria-valuemin={header.column.columnDef.minSize}
1862
+ aria-valuemax={
1863
+ resizeMax !== undefined && resizeMax < Number.MAX_SAFE_INTEGER
1864
+ ? resizeMax
1865
+ : Math.max(header.getSize(), RESIZE_UNBOUNDED_ARIA_MAX)
1866
+ }
1867
+ // #51: a bare number reads to AT as a dimensionless
1868
+ // ordinal ("150") rather than a size — aria-valuetext
1869
+ // supplies the unit while aria-valuenow (above) stays
1870
+ // the plain numeric value TanStack/AT expect. PR #81
1871
+ // review, "Format the announced resize value for the
1872
+ // active locale": `count` (the raw number) drives
1873
+ // PluralMessage category selection so a locale whose
1874
+ // plural rules pick something other than "other" is
1875
+ // reachable, and `size` goes through `formatNumber` so
1876
+ // an overriding locale renders its own digits/grouping
1877
+ // instead of a raw Latin-digit JS number.
1878
+ aria-valuetext={t("data.table.resizeColumnValue", {
1879
+ count: Math.round(header.getSize()),
1880
+ size: formatNumber(Math.round(header.getSize())),
1881
+ })}
1882
+ aria-label={t("data.table.resizeColumn", { name: headerLabel })}
1883
+ tabIndex={0}
1884
+ data-slot="data-table-resize-handle"
1885
+ onMouseDown={header.getResizeHandler()}
1886
+ onTouchStart={header.getResizeHandler()}
1887
+ onKeyDown={(event) => handleResizeKeyDown(event, header.column)}
1888
+ // #51: double-click resets the column to its declared
1889
+ // (or default) size — see `handleResizeDoubleClick`.
1890
+ // Pointer-only; it doesn't touch the keyboard path above.
1891
+ onDoubleClick={() => handleResizeDoubleClick(header.column)}
1892
+ className={cn(
1893
+ // #51: the hit box is a literal 24px (clamped to half
1894
+ // the header cell so it can never overlap a neighbour,
1895
+ // even at `minSize=20`) rather than the `w-2` Tailwind
1896
+ // spacing-scale utility. `w-2` compiles to
1897
+ // `calc(var(--spacing) * 2)`, and `--spacing` is what
1898
+ // `data-density="compact"` rescales — so the old 8px
1899
+ // hit box shrank further under compact density
1900
+ // (~7.1px). A literal px value is density-independent
1901
+ // by construction, which is the actual defect the
1902
+ // maintainer's review corrected (NOT `--type-factor`,
1903
+ // which this handle never used). Do not widen via
1904
+ // overhang into the neighbouring cell instead — on the
1905
+ // last column that lands inside the `overflow-auto`
1906
+ // box (#330 false positive) and a pinned neighbour
1907
+ // paints over/hit-tests away the extra area.
1908
+ "absolute inset-y-0 end-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
1909
+ // #51: the focus ring moves to the `after:` pseudo-
1910
+ // element (the drawn seam) rather than the box itself
1911
+ // — the box is now a 24px hit target, and a 24px focus
1912
+ // rectangle would replace the deliberately slim ring
1913
+ // already reviewed/approved as the #12 a11y fix
1914
+ // (da9b29e). `focus-visible:after:*` targets the
1915
+ // pseudo-element the same way `hover:after:w-2` /
1916
+ // `focus-visible:after:w-2` below already do.
1917
+ "focus-visible:outline-none",
1918
+ // a11y fix (#12 review, blocking): this handle is the
1919
+ // SOLE boundary between two adjacent header cells once
1920
+ // resizing is on — no fill/elevation change separates
1921
+ // them otherwise — so per the border/border-strong
1922
+ // decision test (styling-and-tokens.md) it needs a
1923
+ // rung that clears WCAG 1.4.11's 3:1 on its OWN, in
1924
+ // EVERY state, including rest (a control with no
1925
+ // affordance until hover is unusable without a
1926
+ // pointer). `border-strong` measures only 2.86-2.96:1
1927
+ // against this `bg-surface-muted` header — that rung
1928
+ // is guaranteed only vs `--card`/`--background`, not a
1929
+ // same-tone surface, which is the exact trap the rule
1930
+ // warns about. `muted-foreground` is guaranteed AA
1931
+ // text contrast against `--surface-muted`
1932
+ // (TEXT_SURFACES), so it clears the 3:1 non-text
1933
+ // minimum with wide margin (measured ~5.3-6.4:1 in
1934
+ // both themes, unaffected by density) and is already
1935
+ // the header's own label color. A slim persistent
1936
+ // `after:` seam (not just a hover reveal) gives the
1937
+ // real resting boundary; hover/focus widen the drawn
1938
+ // seam to 8px (`after:w-2`) using the same compliant
1939
+ // color — a separate width from the 24px pointer hit
1940
+ // box below (#51), which the seam does not fill.
1941
+ // Dragging keeps the pre-existing full-fill
1942
+ // `bg-primary` treatment — that is a drag AFFORDANCE,
1943
+ // not a focus indicator, and it is redundant with the
1944
+ // pointer capture, so it is out of scope here. The
1945
+ // keyboard focus indicator on both branches is the
1946
+ // shared compound one (#67), applied to the drawn seam
1947
+ // via `focus-visible:after:focus-ring-static`: the
1948
+ // element itself is a 24px transparent hit box, so
1949
+ // ringing IT would ring nothing a user can see.
1950
+ header.column.getIsResizing()
1951
+ ? "after:absolute after:inset-y-0 after:end-0 after:w-2 after:bg-primary after:content-[''] focus-visible:after:focus-ring-static"
1952
+ : "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",
1953
+ )}
1954
+ />
1955
+ )}
917
1956
  </th>
918
1957
  );
919
1958
  })}
@@ -984,17 +2023,16 @@ function DataTableInner<TData, TValue>(
984
2023
 
985
2024
  /**
986
2025
  * 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.
2026
+ * caller's `rowActionLabel`, then the row's first DATA column value (via
2027
+ * `firstDataCellValue`skips a leading display column with no accessor,
2028
+ * e.g. `createSelectionColumn()`'s own checkbox column, #11 I6), then the
2029
+ * localized generic fallback.
991
2030
  */
992
2031
  function rowActionName(row: (typeof rows)[number]): string {
993
2032
  const explicit = rowActionLabel?.(row);
994
2033
  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);
2034
+ const name = firstDataCellValue(row);
2035
+ if (name !== undefined) return name;
998
2036
  return t("data.table.rowAction");
999
2037
  }
1000
2038
 
@@ -1003,6 +2041,18 @@ function DataTableInner<TData, TValue>(
1003
2041
  row: (typeof rows)[number],
1004
2042
  rowIndex: number,
1005
2043
  extras?: React.HTMLAttributes<HTMLTableRowElement>,
2044
+ // Reorder metadata for THIS row, present in either handle mode whenever
2045
+ // reorder is active — `activator` is set only in `"cell"` mode, where the
2046
+ // grip button (not the row) is the drag activator (dnd-kit's
2047
+ // `setActivatorNodeRef` pattern).
2048
+ dragHandle?: {
2049
+ isDragging: boolean;
2050
+ activator?: {
2051
+ setActivatorNodeRef: (node: HTMLElement | null) => void;
2052
+ attributes: DraggableAttributes;
2053
+ listeners: DraggableSyntheticListeners;
2054
+ };
2055
+ },
1006
2056
  ) {
1007
2057
  // #337: `onRowClick` adds exactly ONE activation target per row — a
1008
2058
  // visually-hidden <button> in the first cell. The <tr> stays a plain `row`
@@ -1035,6 +2085,16 @@ function DataTableInner<TData, TValue>(
1035
2085
  // pair already collapses toward ~0ms via --motion-factor when the user
1036
2086
  // or OS asks for reduced motion, matching the header sort button.
1037
2087
  "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
2088
+ // #13: the dragged row's live `transform` (set inline via `extras.style`,
2089
+ // see `SortableDataRow`) is what actually MOVES it — this class only
2090
+ // makes that movement glide instead of snapping, through the gated
2091
+ // duration/ease utilities (never a raw ms/ease value —
2092
+ // quality-gates.md "Motion-tokened") with a reduced-motion
2093
+ // neutralizer. Raising the dragged row's stacking + opacity is a
2094
+ // colour/composite-only cue, so it isn't gated by the same rule.
2095
+ dragHandle &&
2096
+ "relative transition-transform duration-base ease-standard motion-reduce:transition-none",
2097
+ dragHandle?.isDragging && "z-20 opacity-90 shadow-md",
1038
2098
  // Named group (#333) so a PINNED cell can re-apply the row's hover /
1039
2099
  // selected wash on top of its own opaque fill — only CSS knows the
1040
2100
  // pointer is over a sibling cell. Purely a selector hook: `group/row`
@@ -1048,20 +2108,45 @@ function DataTableInner<TData, TValue>(
1048
2108
  // ring paints on the ROW the user is about to activate, even though
1049
2109
  // focus lives on the sr-only control inside it.
1050
2110
  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",
2111
+ "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:focus-ring-static-inset",
1052
2112
  rowClassName?.(row),
1053
2113
  )}
1054
2114
  {...extras}
1055
2115
  >
2116
+ {dragHandle?.activator && (
2117
+ <td className="w-10 px-3 py-2 align-middle">
2118
+ <button
2119
+ type="button"
2120
+ ref={dragHandle.activator.setActivatorNodeRef}
2121
+ data-slot="data-table-row-drag-handle"
2122
+ aria-label={t("data.table.reorderHandle", { name: rowActionName(row) })}
2123
+ className={cn(
2124
+ "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",
2125
+ dragHandle.isDragging && "text-foreground",
2126
+ )}
2127
+ {...dragHandle.activator.attributes}
2128
+ {...dragHandle.activator.listeners}
2129
+ >
2130
+ <GripVertical aria-hidden="true" className="size-4" />
2131
+ </button>
2132
+ </td>
2133
+ )}
1056
2134
  {row.getVisibleCells().map((cell, cellIndex) => {
1057
2135
  const geometry = pinnedCellGeometry(cell.column);
2136
+ // #12: same width triad as the header cell — see `resizeWidthStyle`.
2137
+ const resizeStyle = enableColumnResizing
2138
+ ? resizeWidthStyle(cell.column.getSize())
2139
+ : undefined;
1058
2140
  return (
1059
2141
  <td
1060
2142
  key={cell.id}
1061
2143
  data-pinned={geometry?.pinned ?? undefined}
1062
- style={geometry?.style}
2144
+ style={geometry?.style ?? resizeStyle}
1063
2145
  className={cn(
1064
2146
  "px-3 py-2 align-middle",
2147
+ // #69: same numeric-column seam as the header — see
2148
+ // `numericColumnClasses`.
2149
+ numericColumnClasses(cell.column.columnDef.meta),
1065
2150
  // z-10: above the normal (unpositioned) cells it scrolls over,
1066
2151
  // below the sticky header row (z-20) and the pinned corner (z-30).
1067
2152
  geometry && "sticky z-10",
@@ -1074,7 +2159,12 @@ function DataTableInner<TData, TValue>(
1074
2159
  <button
1075
2160
  type="button"
1076
2161
  data-slot="data-table-row-action"
1077
- className="sr-only"
2162
+ // #311: `sr-only` removes the box from the visual layout but
2163
+ // not the browser's own focus ring — the ROW paints the
2164
+ // deliberate compound indicator (via the `has-[…]` selector
2165
+ // above), so the proxy's own native ring must be suppressed
2166
+ // or it leaks as a stray dot at the row's edge.
2167
+ className="sr-only focus-visible:outline-none"
1078
2168
  onClick={(event) => onRowClick?.(row, event)}
1079
2169
  >
1080
2170
  {rowActionName(row)}
@@ -1093,10 +2183,24 @@ function DataTableInner<TData, TValue>(
1093
2183
  * renderers so a markup/token/a11y fix only needs to be made once (#231).
1094
2184
  */
1095
2185
  function renderSkeletonBody(count: number) {
2186
+ // #69: iterate the real leaf columns (not just a count) so each skeleton
2187
+ // `<td>` can read the same `meta.numeric`/`meta.align` as the loaded
2188
+ // header/body cells — a loading table whose skeleton didn't mirror the
2189
+ // real alignment is exactly the column-shift-on-load bug
2190
+ // loading-states.md § "CLS / space reservation" warns about.
2191
+ const visibleColumns = table.getVisibleLeafColumns();
1096
2192
  return Array.from({ length: count }).map((_, i) => (
1097
2193
  <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">
2194
+ {hasGripColumn && (
2195
+ <td className="w-10 px-3 py-2 align-middle">
2196
+ <Skeleton className="size-4" />
2197
+ </td>
2198
+ )}
2199
+ {visibleColumns.map((column) => (
2200
+ <td
2201
+ key={column.id}
2202
+ className={cn("px-3 py-2 align-middle", numericColumnClasses(column.columnDef.meta))}
2203
+ >
1100
2204
  <Skeleton className="h-4 w-full" />
1101
2205
  </td>
1102
2206
  ))}
@@ -1111,7 +2215,10 @@ function DataTableInner<TData, TValue>(
1111
2215
  function renderEmptyBody() {
1112
2216
  return (
1113
2217
  <tr>
1114
- <td colSpan={colCount} className="h-24 px-3 text-center text-muted-foreground">
2218
+ <td
2219
+ colSpan={colCount + (hasGripColumn ? 1 : 0)}
2220
+ className="h-24 px-3 text-center text-muted-foreground"
2221
+ >
1115
2222
  {emptyMessage}
1116
2223
  </td>
1117
2224
  </tr>
@@ -1123,8 +2230,68 @@ function DataTableInner<TData, TValue>(
1123
2230
  if (showSkeletons) {
1124
2231
  return <tbody>{renderSkeletonBody(skeletonRowCount)}</tbody>;
1125
2232
  }
2233
+ if (showEmpty) {
2234
+ return <tbody>{renderEmptyBody()}</tbody>;
2235
+ }
2236
+ if (!rowReorderActive) {
2237
+ return <tbody>{rows.map((row, i) => renderRow(row, i))}</tbody>;
2238
+ }
1126
2239
 
1127
- return <tbody>{showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i))}</tbody>;
2240
+ // #13: `SortableContext` renders no DOM element of its own (a plain
2241
+ // context Provider), so nesting it around `<tbody>` here does not insert
2242
+ // anything between `<table>` and `<tbody>` — the real DOM stays valid.
2243
+ return (
2244
+ <SortableContext
2245
+ items={rows.map((r) => getReorderRowId(r))}
2246
+ strategy={verticalListSortingStrategy}
2247
+ >
2248
+ <tbody>
2249
+ {rows.map((row, i) => (
2250
+ <SortableDataRow
2251
+ key={getReorderRowId(row)}
2252
+ id={getReorderRowId(row)}
2253
+ attributesOverride={{
2254
+ // #98: dnd-kit's own `roleDescription: 'sortable'` default is
2255
+ // hardcoded English; override it with the localized value in
2256
+ // BOTH handle modes — `role` stays row-mode-only (see the
2257
+ // `attributesOverride` prop doc above).
2258
+ roleDescription: t("data.table.reorderRoleDescription"),
2259
+ ...(rowReorderHandle === "row" ? { role: "row" } : null),
2260
+ }}
2261
+ >
2262
+ {({ setNodeRef, setActivatorNodeRef, attributes, listeners, isDragging, style }) =>
2263
+ renderRow(
2264
+ row,
2265
+ i,
2266
+ {
2267
+ ref: setNodeRef,
2268
+ style,
2269
+ // `aria-pressed` is a `DraggableAttributes` field meant for a
2270
+ // real `<button>` activator; spread onto a `<tr role="row">`
2271
+ // (row-handle mode) it fails axe's `aria-allowed-attr` (that
2272
+ // ARIA state is not permitted on the `row` role), so strip it
2273
+ // here rather than exempt it downstream.
2274
+ ...(rowReorderHandle === "row"
2275
+ ? (() => {
2276
+ const { "aria-pressed": _ariaPressed, ...rowAttributes } = attributes;
2277
+ return { ...rowAttributes, ...listeners };
2278
+ })()
2279
+ : {}),
2280
+ } as React.HTMLAttributes<HTMLTableRowElement>,
2281
+ {
2282
+ isDragging,
2283
+ activator:
2284
+ rowReorderHandle === "cell"
2285
+ ? { setActivatorNodeRef, attributes, listeners }
2286
+ : undefined,
2287
+ },
2288
+ )
2289
+ }
2290
+ </SortableDataRow>
2291
+ ))}
2292
+ </tbody>
2293
+ </SortableContext>
2294
+ );
1128
2295
  }
1129
2296
 
1130
2297
  // ─── Virtualized tbody ────────────────────────────────────────────────────
@@ -1240,7 +2407,7 @@ function DataTableInner<TData, TValue>(
1240
2407
  // here would add a redundant landmark over the inner real <table>.
1241
2408
  aria-label={t("data.table.scrollRegion")}
1242
2409
  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"
2410
+ className="relative overflow-auto rounded-lg border bg-card focus-ring"
1244
2411
  style={{ maxHeight: maxBodyHeight, ...pinnedScrollPadding }}
1245
2412
  >
1246
2413
  {/* Loading overlay */}
@@ -1281,7 +2448,7 @@ function DataTableInner<TData, TValue>(
1281
2448
  // fades) and an INNER scrolling div (the focusable, `overflow-auto` scroll
1282
2449
  // region) so the edge-fade affordance can stay pinned to the visible edges
1283
2450
  // instead of scrolling away with the table content.
1284
- return (
2451
+ const nonVirtualizedContent = (
1285
2452
  <div ref={ref} className={cn("space-y-3", className)} {...rest}>
1286
2453
  {toolbar ? toolbar(table) : null}
1287
2454
  {/* Outer border is redundant (surface change) → plain border per #173 spec */}
@@ -1318,7 +2485,7 @@ function DataTableInner<TData, TValue>(
1318
2485
  tabIndex={scrollOverflows ? 0 : undefined}
1319
2486
  aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
1320
2487
  onScroll={updateScrollAffordance}
1321
- className="overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
2488
+ className="overflow-auto rounded-lg focus-ring-inset"
1322
2489
  style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
1323
2490
  >
1324
2491
  <table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
@@ -1356,6 +2523,45 @@ function DataTableInner<TData, TValue>(
1356
2523
  {renderPagination()}
1357
2524
  </div>
1358
2525
  );
2526
+
2527
+ // #13: `DndContext` renders no wrapping DOM element around `children` either
2528
+ // — it composes `children` alongside its own hidden a11y nodes (the
2529
+ // screen-reader instructions, plus a `role="status"` `LiveRegion` that is
2530
+ // permanently silent — see `silentDragAnnouncements` above) as SIBLINGS.
2531
+ // Wrapping the whole component root here (rather than reaching inside the
2532
+ // `<table>`) is what keeps those hidden nodes out of the table's own DOM —
2533
+ // they land beside the table's outer `<div>`, never inside a
2534
+ // `<thead>`/`<tbody>`, which is the only place in HTML that would reject
2535
+ // them. DataTable's OWN `aria-live="polite"` region (`reorderLiveMessage`)
2536
+ // is a further sibling here for the same reason.
2537
+ if (!rowReorderActive) return nonVirtualizedContent;
2538
+ return (
2539
+ <DndContext
2540
+ sensors={reorderSensors}
2541
+ collisionDetection={closestCenter}
2542
+ onDragStart={handleRowDragStart}
2543
+ onDragOver={handleRowDragOver}
2544
+ onDragEnd={handleRowDragEnd}
2545
+ onDragCancel={handleRowDragCancel}
2546
+ accessibility={{
2547
+ announcements: silentDragAnnouncements,
2548
+ // #98: dnd-kit's own hidden keyboard-instructions node is hardcoded
2549
+ // English (`defaultScreenReaderInstructions`) unless overridden here.
2550
+ screenReaderInstructions: { draggable: t("data.table.reorderInstructions") },
2551
+ }}
2552
+ >
2553
+ {nonVirtualizedContent}
2554
+ <div
2555
+ role="status"
2556
+ aria-live="polite"
2557
+ aria-atomic="true"
2558
+ data-slot="data-table-reorder-live-region"
2559
+ className="sr-only"
2560
+ >
2561
+ {reorderLiveMessage}
2562
+ </div>
2563
+ </DndContext>
2564
+ );
1359
2565
  }
1360
2566
 
1361
2567
  // ─── Public export with forwardRef + generic cast ─────────────────────────────