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