@elabs-ai/components-data 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/index.d.ts +192 -3
- package/dist/index.js +710 -130
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
- package/src/column-picker/column-picker.tsx +3 -1
- package/src/data-table/data-table.stories.tsx +481 -2
- package/src/data-table/data-table.test.tsx +1370 -6
- package/src/data-table/data-table.tsx +1231 -25
- package/src/data-table/index.ts +10 -0
- package/src/facet-filter/facet-filter.tsx +5 -1
- package/src/filter-bar/filter-chip.stories.tsx +144 -0
- package/src/filter-bar/filter-chip.test.tsx +137 -0
- package/src/filter-bar/filter-chip.tsx +92 -0
- package/src/filter-bar/index.ts +1 -0
- package/src/index.ts +10 -1
- package/src/search-input/search-input.stories.tsx +3 -0
- package/src/search-input/search-input.tsx +1 -1
- package/src/templates-data-app.stories.tsx +6 -4
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
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
|
|
996
|
-
if (
|
|
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]:
|
|
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
|
-
|
|
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
|
-
{
|
|
1099
|
-
<td
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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 ─────────────────────────────
|