@elabs-ai/components-data 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  // src/data-table/data-table.tsx
4
- import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState
11
+ } from "react";
5
12
  import {
6
13
  flexRender,
7
14
  getCoreRowModel,
@@ -11,10 +18,30 @@ import {
11
18
  useReactTable
12
19
  } from "@tanstack/react-table";
13
20
  import { useVirtualizer } from "@tanstack/react-virtual";
14
- import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
15
- import { Button, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
21
+ import {
22
+ DndContext,
23
+ KeyboardSensor,
24
+ PointerSensor,
25
+ closestCenter,
26
+ useSensor,
27
+ useSensors
28
+ } from "@dnd-kit/core";
29
+ import {
30
+ SortableContext,
31
+ sortableKeyboardCoordinates,
32
+ useSortable,
33
+ verticalListSortingStrategy
34
+ } from "@dnd-kit/sortable";
35
+ import { CSS } from "@dnd-kit/utilities";
36
+ import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from "lucide-react";
37
+ import { Button, Checkbox, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
16
38
  import { cn } from "@elabs-ai/components-ui/lib/cn";
17
39
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
40
+ function numericColumnClasses(meta) {
41
+ if (!meta?.numeric && !meta?.align) return void 0;
42
+ const alignClass = meta?.align === "start" ? "text-start" : meta?.align === "center" ? "text-center" : meta?.align === "end" ? "text-end" : meta?.numeric ? "text-end" : void 0;
43
+ return cn(alignClass, meta?.numeric && "tabular-nums");
44
+ }
18
45
  var ROW_CLICK_GUARD_SELECTOR = 'button, a[href], input, select, textarea, label, summary, [role="button"], [role="link"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [contenteditable="true"]';
19
46
  function isInteractiveEventTarget(target) {
20
47
  return target instanceof Element && target.closest(ROW_CLICK_GUARD_SELECTOR) !== null;
@@ -42,6 +69,78 @@ function unsizedColumnIds(defs) {
42
69
  walk(defs);
43
70
  return out;
44
71
  }
72
+ function resizeWidthStyle(size) {
73
+ return { width: size, minWidth: size, maxWidth: size };
74
+ }
75
+ function firstDataCellValue(row) {
76
+ for (const cell of row.getVisibleCells()) {
77
+ if (!cell.column.accessorFn) continue;
78
+ const value = cell.getValue();
79
+ if (typeof value === "string" && value.trim() !== "") return value;
80
+ if (typeof value === "number") return String(value);
81
+ }
82
+ return void 0;
83
+ }
84
+ function SelectAllHeaderCell({ table }) {
85
+ const { t } = useLocale();
86
+ const allSelected = table.getIsAllPageRowsSelected();
87
+ const someSelected = table.getIsSomePageRowsSelected();
88
+ return /* @__PURE__ */ jsx(
89
+ Checkbox,
90
+ {
91
+ "data-slot": "data-table-select-all",
92
+ checked: allSelected ? true : someSelected ? "indeterminate" : false,
93
+ onCheckedChange: (checked) => table.toggleAllPageRowsSelected(checked === true),
94
+ "aria-label": t("data.table.selectAllRows")
95
+ }
96
+ );
97
+ }
98
+ function SelectRowCell({ row }) {
99
+ const { t } = useLocale();
100
+ const name = firstDataCellValue(row);
101
+ return /* @__PURE__ */ jsx(
102
+ Checkbox,
103
+ {
104
+ "data-slot": "data-table-select-cell",
105
+ checked: row.getIsSelected(),
106
+ disabled: !row.getCanSelect(),
107
+ onCheckedChange: (checked) => row.toggleSelected(checked === true),
108
+ "aria-label": name ? t("data.table.selectRowNamed", { name }) : t("data.table.selectRow")
109
+ }
110
+ );
111
+ }
112
+ function createSelectionColumn() {
113
+ return {
114
+ id: "select",
115
+ size: 40,
116
+ enableSorting: false,
117
+ enableHiding: false,
118
+ header: ({ table }) => (
119
+ // #11 C1: `toggleAllPageRowsSelected` wipes-then-sets on every row when
120
+ // `enableMultiRowSelection` is off (TanStack's `mutateRowIsSelected`), so
121
+ // a select-all header under single-select leaves only the LAST row
122
+ // selected and pins the header at indeterminate forever. Suppress it.
123
+ table.options.enableMultiRowSelection === false ? null : /* @__PURE__ */ jsx(SelectAllHeaderCell, { table })
124
+ ),
125
+ cell: ({ row }) => /* @__PURE__ */ jsx(SelectRowCell, { row })
126
+ };
127
+ }
128
+ function SortableDataRow({
129
+ id,
130
+ disabled,
131
+ attributesOverride,
132
+ children
133
+ }) {
134
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging } = useSortable({ id, disabled, transition: null, attributes: attributesOverride });
135
+ return /* @__PURE__ */ jsx(Fragment, { children: children({
136
+ setNodeRef,
137
+ setActivatorNodeRef,
138
+ attributes,
139
+ listeners,
140
+ isDragging,
141
+ style: { transform: CSS.Transform.toString(transform) }
142
+ }) });
143
+ }
45
144
  function DataTableInner({
46
145
  columns,
47
146
  data,
@@ -63,6 +162,15 @@ function DataTableInner({
63
162
  onPaginationChange: onPaginationChangeProp,
64
163
  columnPinning: columnPinningProp,
65
164
  onColumnPinningChange: onColumnPinningChangeProp,
165
+ enableColumnResizing = false,
166
+ columnResizeMode = "onChange",
167
+ columnSizing: columnSizingProp,
168
+ onColumnSizingChange: onColumnSizingChangeProp,
169
+ rowSelection: rowSelectionProp,
170
+ onRowSelectionChange: onRowSelectionChangeProp,
171
+ enableRowSelection,
172
+ enableMultiRowSelection,
173
+ getRowId,
66
174
  // Saved views rehydration
67
175
  initialView,
68
176
  // Server-side model
@@ -81,6 +189,10 @@ function DataTableInner({
81
189
  overscan = 8,
82
190
  maxBodyHeight = "32rem",
83
191
  zebra = true,
192
+ // Row drag-reorder (#13)
193
+ enableRowReorder = false,
194
+ onRowReorder,
195
+ rowReorderHandle = "cell",
84
196
  onRowClick,
85
197
  rowActionLabel,
86
198
  rowClassName,
@@ -89,13 +201,15 @@ function DataTableInner({
89
201
  className,
90
202
  ...rest
91
203
  }, ref) {
92
- const { t } = useLocale();
204
+ const { t, dir, formatNumber } = useLocale();
93
205
  const isSortingControlled = sortingProp !== void 0;
94
206
  const isColumnVisibilityControlled = columnVisibilityProp !== void 0;
95
207
  const isColumnFiltersControlled = columnFiltersProp !== void 0;
96
208
  const isPaginationControlled = paginationProp !== void 0;
97
209
  const isFilterControlled = globalFilterProp !== void 0;
98
210
  const isColumnPinningControlled = columnPinningProp !== void 0;
211
+ const isColumnSizingControlled = columnSizingProp !== void 0;
212
+ const isRowSelectionControlled = rowSelectionProp !== void 0;
99
213
  const [internalSorting, setInternalSorting] = useState(
100
214
  () => initialView?.sorting ?? []
101
215
  );
@@ -117,12 +231,20 @@ function DataTableInner({
117
231
  const [internalColumnPinning, setInternalColumnPinning] = useState(
118
232
  () => initialView?.columnPinning ?? { left: [], right: [] }
119
233
  );
234
+ const [internalColumnSizing, setInternalColumnSizing] = useState(
235
+ () => initialView?.columnSizing ?? {}
236
+ );
237
+ const [internalRowSelection, setInternalRowSelection] = useState(
238
+ () => initialView?.rowSelection ?? {}
239
+ );
120
240
  const sorting = isSortingControlled ? sortingProp : internalSorting;
121
241
  const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility;
122
242
  const columnFilters = isColumnFiltersControlled ? columnFiltersProp : internalColumnFilters;
123
243
  const pagination = isPaginationControlled ? paginationProp : internalPagination;
124
244
  const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
125
245
  const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
246
+ const columnSizing = isColumnSizingControlled ? columnSizingProp : internalColumnSizing;
247
+ const rowSelection = isRowSelectionControlled ? rowSelectionProp : internalRowSelection;
126
248
  const sortingRef = useRef(sorting);
127
249
  sortingRef.current = sorting;
128
250
  const columnFiltersRef = useRef(columnFilters);
@@ -135,6 +257,10 @@ function DataTableInner({
135
257
  columnVisibilityRef.current = columnVisibility;
136
258
  const columnPinningRef = useRef(columnPinning);
137
259
  columnPinningRef.current = columnPinning;
260
+ const columnSizingRef = useRef(columnSizing);
261
+ columnSizingRef.current = columnSizing;
262
+ const rowSelectionRef = useRef(rowSelection);
263
+ rowSelectionRef.current = rowSelection;
138
264
  const warnedMissingRowCountRef = useRef(false);
139
265
  useEffect(() => {
140
266
  if (process.env.NODE_ENV !== "production" && manualPagination && rowCount === void 0 && pageCount === void 0 && !warnedMissingRowCountRef.current) {
@@ -144,6 +270,54 @@ function DataTableInner({
144
270
  );
145
271
  }
146
272
  }, [manualPagination, rowCount, pageCount]);
273
+ const warnedManualSelectionRef = useRef(false);
274
+ useEffect(() => {
275
+ if (process.env.NODE_ENV !== "production" && manualPagination && getRowId === void 0 && (isRowSelectionControlled || onRowSelectionChangeProp !== void 0) && !warnedManualSelectionRef.current) {
276
+ warnedManualSelectionRef.current = true;
277
+ console.warn(
278
+ '[DataTable] `rowSelection` is wired up under `manualPagination` with no `getRowId` \u2014 each page is a fresh `data` array, so the default index-based id restarts at "0" per page and a selection made on one page can silently apply to a different record on the next. Pass `getRowId` so selection is keyed to a stable identity instead of position.'
279
+ );
280
+ }
281
+ }, [manualPagination, getRowId, isRowSelectionControlled, onRowSelectionChangeProp]);
282
+ const warnedReorderSortingRef = useRef(false);
283
+ useEffect(() => {
284
+ if (process.env.NODE_ENV !== "production" && enableRowReorder && sorting.length > 0 && !warnedReorderSortingRef.current) {
285
+ warnedReorderSortingRef.current = true;
286
+ console.warn(
287
+ "[DataTable] `enableRowReorder` is set while a column is sorted \u2014 the sort will keep re-ordering rows out from under a manual drag. Clear `sorting` (or avoid enabling both at once) so a drag's new order stays stable."
288
+ );
289
+ }
290
+ }, [enableRowReorder, sorting.length]);
291
+ const warnedReorderVirtualizedRef = useRef(false);
292
+ useEffect(() => {
293
+ if (process.env.NODE_ENV !== "production" && enableRowReorder && enableRowVirtualization && !warnedReorderVirtualizedRef.current) {
294
+ warnedReorderVirtualizedRef.current = true;
295
+ console.warn(
296
+ "[DataTable] `enableRowReorder` has no effect while `enableRowVirtualization` is set \u2014 the two are mutually exclusive. Virtualization wins; row reorder is disabled."
297
+ );
298
+ }
299
+ }, [enableRowReorder, enableRowVirtualization]);
300
+ const rowReorderActive = enableRowReorder && !enableRowVirtualization;
301
+ const hasGripColumn = rowReorderActive && rowReorderHandle === "cell";
302
+ const reorderSensors = useSensors(
303
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
304
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
305
+ );
306
+ const reorderIdentityMapRef = useRef(/* @__PURE__ */ new WeakMap());
307
+ const reorderIdentityCounterRef = useRef(0);
308
+ const reorderRepeatedPositions = useMemo(() => {
309
+ const repeats = /* @__PURE__ */ new Set();
310
+ if (!rowReorderActive) return repeats;
311
+ const seen = /* @__PURE__ */ new Set();
312
+ data.forEach((record, index) => {
313
+ if (record === null || typeof record !== "object") return;
314
+ if (seen.has(record)) repeats.add(index);
315
+ else seen.add(record);
316
+ });
317
+ return repeats;
318
+ }, [data, rowReorderActive]);
319
+ const [reorderLiveMessage, setReorderLiveMessage] = useState("");
320
+ const reorderLastAnnouncedPositionRef = useRef(null);
147
321
  function fireServerChange(overrides = {}) {
148
322
  if (!onServerChange) return;
149
323
  onServerChange({
@@ -172,13 +346,28 @@ function DataTableInner({
172
346
  function resolveColumnPinning(updater) {
173
347
  return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
174
348
  }
349
+ function resolveColumnSizing(updater) {
350
+ return typeof updater === "function" ? updater(columnSizingRef.current) : updater;
351
+ }
352
+ function resolveRowSelection(updater) {
353
+ return typeof updater === "function" ? updater(rowSelectionRef.current) : updater;
354
+ }
175
355
  const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
176
356
  const filteredRowModel = manualFiltering ? {} : { getFilteredRowModel: getFilteredRowModel() };
177
357
  const paginationRowModel = enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
178
358
  const table = useReactTable({
179
359
  data,
180
360
  columns,
181
- state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
361
+ state: {
362
+ sorting,
363
+ columnVisibility,
364
+ columnFilters,
365
+ globalFilter,
366
+ pagination,
367
+ columnPinning,
368
+ columnSizing,
369
+ rowSelection
370
+ },
182
371
  // Sorting
183
372
  onSortingChange: (updater) => {
184
373
  const next = resolveSorting(updater);
@@ -233,6 +422,38 @@ function DataTableInner({
233
422
  if (!isColumnPinningControlled) setInternalColumnPinning(next);
234
423
  onColumnPinningChangeProp?.(updater);
235
424
  },
425
+ // Column resizing (#12) — a LAYOUT slice, like column pinning: a column's
426
+ // width changes nothing the server would need to re-query, so this never
427
+ // fires onServerChange either. Routed through by BOTH the pointer path
428
+ // (TanStack's own `header.getResizeHandler()`, wired below) and the
429
+ // keyboard path (`handleResizeKeyDown`, via `table.setColumnSizing`) so
430
+ // the two input modes can never diverge in controlled/uncontrolled
431
+ // behaviour.
432
+ columnResizeMode,
433
+ // RTL fix (#12 review, P1): TanStack's pointer-drag math hardcodes LTR
434
+ // unless told otherwise — `deltaDirection = columnResizeDirection ===
435
+ // 'rtl' ? -1 : 1` internally — so under `dir="rtl"` (the resize handle's
436
+ // own edge already flips via `end-0`, see the `useLocale()` call above)
437
+ // dragging would otherwise move the column's width opposite the visible
438
+ // boundary. `handleResizeKeyDown` below mirrors this for the keyboard path.
439
+ columnResizeDirection: dir,
440
+ enableColumnResizing,
441
+ onColumnSizingChange: (updater) => {
442
+ const next = resolveColumnSizing(updater);
443
+ if (!isColumnSizingControlled) setInternalColumnSizing(next);
444
+ onColumnSizingChangeProp?.(updater);
445
+ },
446
+ // Row selection (#11) — also a LAYOUT/UI slice, so it never fires
447
+ // onServerChange: which rows are checked changes nothing the server
448
+ // would need to re-query.
449
+ onRowSelectionChange: (updater) => {
450
+ const next = resolveRowSelection(updater);
451
+ if (!isRowSelectionControlled) setInternalRowSelection(next);
452
+ onRowSelectionChangeProp?.(updater);
453
+ },
454
+ enableRowSelection,
455
+ enableMultiRowSelection,
456
+ getRowId,
236
457
  getCoreRowModel: getCoreRowModel(),
237
458
  ...sortedRowModel,
238
459
  ...filteredRowModel,
@@ -251,6 +472,83 @@ function DataTableInner({
251
472
  const colCount = table.getVisibleLeafColumns().length;
252
473
  const headerRowCount = table.getHeaderGroups().length;
253
474
  const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
475
+ function reorderRowName(id) {
476
+ const row = rows.find((r) => getReorderRowId(r) === id);
477
+ return row ? rowActionName(row) : id;
478
+ }
479
+ function reorderPosition(id) {
480
+ return rows.findIndex((r) => getReorderRowId(r) === id) + 1;
481
+ }
482
+ function getReorderRowId(row) {
483
+ if (getRowId) return row.id;
484
+ const original = row.original;
485
+ if (original !== null && typeof original === "object") {
486
+ const map = reorderIdentityMapRef.current;
487
+ let id = map.get(original);
488
+ if (id === void 0) {
489
+ id = `__reorder-${reorderIdentityCounterRef.current++}`;
490
+ map.set(original, id);
491
+ }
492
+ return reorderRepeatedPositions.has(row.index) ? `${id}__${row.index}` : id;
493
+ }
494
+ return row.id;
495
+ }
496
+ const silentDragAnnouncements = {
497
+ onDragStart: () => void 0,
498
+ onDragOver: () => void 0,
499
+ onDragEnd: () => void 0,
500
+ onDragCancel: () => void 0
501
+ };
502
+ function handleRowDragStart(event) {
503
+ const activeRowId = String(event.active.id);
504
+ reorderLastAnnouncedPositionRef.current = reorderPosition(activeRowId);
505
+ setReorderLiveMessage(t("data.table.reorderPickedUp", { name: reorderRowName(activeRowId) }));
506
+ }
507
+ function handleRowDragOver(event) {
508
+ const { active, over } = event;
509
+ if (!over) return;
510
+ const position = reorderPosition(String(over.id));
511
+ if (position === reorderLastAnnouncedPositionRef.current) return;
512
+ reorderLastAnnouncedPositionRef.current = position;
513
+ setReorderLiveMessage(
514
+ t("data.table.reorderMoved", {
515
+ name: reorderRowName(String(active.id)),
516
+ position,
517
+ total: rows.length
518
+ })
519
+ );
520
+ }
521
+ function handleRowDragCancel(event) {
522
+ const activeRowId = String(event.active.id);
523
+ setReorderLiveMessage(
524
+ t("data.table.reorderCancelled", {
525
+ name: reorderRowName(activeRowId),
526
+ position: reorderPosition(activeRowId),
527
+ total: rows.length
528
+ })
529
+ );
530
+ reorderLastAnnouncedPositionRef.current = null;
531
+ }
532
+ function handleRowDragEnd(event) {
533
+ const { active, over } = event;
534
+ const activeRowId = String(active.id);
535
+ setReorderLiveMessage(
536
+ t("data.table.reorderDropped", {
537
+ name: reorderRowName(activeRowId),
538
+ position: reorderPosition(String(over ? over.id : active.id)),
539
+ total: rows.length
540
+ })
541
+ );
542
+ reorderLastAnnouncedPositionRef.current = null;
543
+ if (!over || active.id === over.id) return;
544
+ const movedRow = rows.find((r) => getReorderRowId(r) === activeRowId);
545
+ const targetRow = rows.find((r) => getReorderRowId(r) === String(over.id));
546
+ if (!movedRow || !targetRow) return;
547
+ const from = movedRow.index;
548
+ const to = targetRow.index;
549
+ if (from < 0 || from >= data.length || to < 0 || to >= data.length) return;
550
+ onRowReorder?.(from, to, movedRow.original);
551
+ }
254
552
  const hasLeftPinned = (columnPinning.left?.length ?? 0) > 0;
255
553
  const hasRightPinned = (columnPinning.right?.length ?? 0) > 0;
256
554
  const pinnedScrollPadding = {
@@ -298,12 +596,33 @@ function DataTableInner({
298
596
  // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
299
597
  // seam pixel read `143,143,143` (light `--border-strong`) at
300
598
  // scrollLeft 0 and `245,245,245` (the plain cell fill — i.e. GONE) once
301
- // scrolled, in all three themes and on both edges. So the one cue vanished
599
+ // scrolled, in every theme and on both edges. So the one cue vanished
302
600
  // exactly when the freeze was doing something. The `::after` lives in the
303
601
  // sticky cell's own stacking context, so it moves with it.
304
602
  edgeClass: pinned === "left" ? column.getIsLastColumn("left") ? PINNED_SEAM_CLASS + " after:end-0" : "" : column.getIsFirstColumn("right") ? PINNED_SEAM_CLASS + " after:start-0" : ""
305
603
  };
306
604
  }
605
+ const RESIZE_STEP = 10;
606
+ const RESIZE_UNBOUNDED_ARIA_MAX = 2e3;
607
+ function handleResizeKeyDown(event, column) {
608
+ let delta = 0;
609
+ if (event.key === "ArrowRight") delta = RESIZE_STEP;
610
+ else if (event.key === "ArrowLeft") delta = -RESIZE_STEP;
611
+ else return;
612
+ event.preventDefault();
613
+ if (dir === "rtl") delta = -delta;
614
+ const minSize = column.columnDef.minSize ?? 20;
615
+ const maxSize = column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
616
+ const nextSize = Math.min(maxSize, Math.max(minSize, column.getSize() + delta));
617
+ table.setColumnSizing((old) => ({ ...old, [column.id]: nextSize }));
618
+ }
619
+ function handleResizeDoubleClick(column) {
620
+ table.setColumnSizing((old) => {
621
+ if (!(column.id in old)) return old;
622
+ const { [column.id]: _removed, ...rest2 } = old;
623
+ return rest2;
624
+ });
625
+ }
307
626
  const scrollRef = useRef(null);
308
627
  const virtualizer = useVirtualizer({
309
628
  count: enableRowVirtualization ? rows.length : 0,
@@ -356,69 +675,173 @@ function DataTableInner({
356
675
  // delta: nothing else in the table sits between those rungs.
357
676
  sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60"
358
677
  ),
359
- children: table.getHeaderGroups().map((headerGroup, groupIndex) => /* @__PURE__ */ jsx("tr", { "aria-rowindex": withRowIndex ? groupIndex + 1 : void 0, children: headerGroup.headers.map((header) => {
360
- const geometry = pinnedCellGeometry(header.column);
361
- const canSort = header.column.getCanSort();
362
- const sorted = header.column.getIsSorted();
363
- const headerLabel = typeof header.column.columnDef.header === "string" ? header.column.columnDef.header : header.column.id;
364
- const sortStateLabel = sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
365
- const SortIcon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
366
- return /* @__PURE__ */ jsx(
367
- "th",
368
- {
369
- scope: "col",
370
- "aria-sort": canSort ? sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none" : void 0,
371
- "data-pinned": geometry?.pinned ?? void 0,
372
- style: geometry?.style,
373
- className: cn(
374
- "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
375
- // A pinned HEADER cell is the corner where both freezes meet,
376
- // so it stacks above the sticky header row (z-20) which is
377
- // above the pinned body cells (z-10). It needs an OPAQUE
378
- // fill (scrolled header cells pass underneath it), and that
379
- // fill has to composite to exactly what its unpinned
380
- // neighbours show same problem, same two-layer answer as
381
- // `pinnedCellFillClass`:
382
- // sticky branch → the row is already opaque `surface-muted`, so match it.
383
- // plain branch → the row is `surface-muted/60` over the
384
- // container's `card`, so paint `card` and
385
- // re-apply the /60 wash on `::before`.
386
- // Painting the plain branch's corner solid `surface-muted`
387
- // read 4-5/255 darker than the header beside it in every
388
- // theme (measured: 242 vs 247 light, 43 vs 40
389
- // dark) the same "floating pill"
390
- // artefact #333 was filed about, moved into the header.
391
- geometry && "sticky z-30",
392
- geometry && (sticky ? "bg-surface-muted" : "bg-card before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-surface-muted/60 before:content-['']"),
393
- // Separate cn() argument on purpose: the seam is the sole
394
- // structural cue between the frozen and scrolling blocks, so
395
- // it must not read as a "boundary + fill in one class string"
396
- // redundancy (separation:check).
397
- geometry?.edgeClass
398
- ),
399
- children: header.isPlaceholder ? null : canSort ? /* @__PURE__ */ jsxs(
400
- "button",
401
- {
402
- type: "button",
403
- onClick: header.column.getToggleSortingHandler(),
404
- "aria-label": `Sort by ${headerLabel}, ${sortStateLabel}`,
405
- className: "inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
406
- children: [
407
- flexRender(header.column.columnDef.header, header.getContext()),
408
- /* @__PURE__ */ jsx(
409
- SortIcon,
410
- {
411
- "aria-hidden": "true",
412
- className: "size-3 shrink-0 transition-colors duration-fast ease-standard"
413
- }
414
- )
415
- ]
416
- }
417
- ) : flexRender(header.column.columnDef.header, header.getContext())
418
- },
419
- header.id
420
- );
421
- }) }, headerGroup.id))
678
+ children: table.getHeaderGroups().map((headerGroup, groupIndex) => /* @__PURE__ */ jsxs("tr", { "aria-rowindex": withRowIndex ? groupIndex + 1 : void 0, children: [
679
+ hasGripColumn && /* @__PURE__ */ jsx("th", { scope: "col", className: "h-10 w-10 px-3 align-middle", children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: t("data.table.reorderColumnHeader") }) }, "__reorder"),
680
+ headerGroup.headers.map((header) => {
681
+ const geometry = pinnedCellGeometry(header.column);
682
+ const canSort = header.column.getCanSort();
683
+ const sorted = header.column.getIsSorted();
684
+ const headerLabel = typeof header.column.columnDef.header === "string" ? header.column.columnDef.header : header.column.id;
685
+ const sortStateLabel = sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
686
+ const SortIcon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
687
+ const resizeStyle = enableColumnResizing ? resizeWidthStyle(header.getSize()) : void 0;
688
+ const canResize = enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
689
+ const resizeMax = header.column.columnDef.maxSize;
690
+ return /* @__PURE__ */ jsxs(
691
+ "th",
692
+ {
693
+ scope: "col",
694
+ "aria-sort": canSort ? sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none" : void 0,
695
+ "data-pinned": geometry?.pinned ?? void 0,
696
+ style: geometry?.style ?? resizeStyle,
697
+ className: cn(
698
+ // Same `px-3` the body `<td>` uses (below) deliberately
699
+ // NOT split into `ps-3`/`pe-3` for a resize-handle
700
+ // override (round-1 briefly did this, see the round-2
701
+ // note on `numericColumnClasses`): the header's padding
702
+ // must stay byte-identical to the body's so an
703
+ // end-aligned numeric column's header lines up with its
704
+ // own values.
705
+ "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
706
+ // #69: a numeric column's `meta` overrides the default
707
+ // `text-start` placed right after the base string so
708
+ // tailwind-merge lets it win over that default.
709
+ numericColumnClasses(header.column.columnDef.meta),
710
+ // `sticky`/pinned already establishes a positioning context
711
+ // for the resize handle's `absolute`; an unpinned resizable
712
+ // header needs its own.
713
+ !geometry && canResize && "relative",
714
+ // A pinned HEADER cell is the corner where both freezes meet,
715
+ // so it stacks above the sticky header row (z-20) which is
716
+ // above the pinned body cells (z-10). It needs an OPAQUE
717
+ // fill (scrolled header cells pass underneath it), and that
718
+ // fill has to composite to exactly what its unpinned
719
+ // neighbours show — same problem, same two-layer answer as
720
+ // `pinnedCellFillClass`:
721
+ // sticky branch → the row is already opaque `surface-muted`, so match it.
722
+ // plain branch → the row is `surface-muted/60` over the
723
+ // container's `card`, so paint `card` and
724
+ // re-apply the /60 wash on `::before`.
725
+ // Painting the plain branch's corner solid `surface-muted`
726
+ // read 4-5/255 darker than the header beside it in every
727
+ // theme (measured: 242 vs 247 light, 43 vs 40
728
+ // dark) — the same "floating pill"
729
+ // artefact #333 was filed about, moved into the header.
730
+ geometry && "sticky z-30",
731
+ geometry && (sticky ? "bg-surface-muted" : "bg-card before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-surface-muted/60 before:content-['']"),
732
+ // Separate cn() argument on purpose: the seam is the sole
733
+ // structural cue between the frozen and scrolling blocks, so
734
+ // it must not read as a "boundary + fill in one class string"
735
+ // redundancy (separation:check).
736
+ geometry?.edgeClass
737
+ ),
738
+ children: [
739
+ header.isPlaceholder ? null : canSort ? /* @__PURE__ */ jsxs(
740
+ "button",
741
+ {
742
+ type: "button",
743
+ onClick: header.column.getToggleSortingHandler(),
744
+ "aria-label": `Sort by ${headerLabel}, ${sortStateLabel}`,
745
+ className: "relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring",
746
+ children: [
747
+ flexRender(header.column.columnDef.header, header.getContext()),
748
+ /* @__PURE__ */ jsx(
749
+ SortIcon,
750
+ {
751
+ "aria-hidden": "true",
752
+ className: "size-3 shrink-0 transition-colors duration-fast ease-standard"
753
+ }
754
+ )
755
+ ]
756
+ }
757
+ ) : flexRender(header.column.columnDef.header, header.getContext()),
758
+ canResize && /* @__PURE__ */ jsx(
759
+ "div",
760
+ {
761
+ role: "separator",
762
+ "aria-orientation": "vertical",
763
+ "aria-valuenow": Math.round(header.getSize()),
764
+ "aria-valuemin": header.column.columnDef.minSize,
765
+ "aria-valuemax": resizeMax !== void 0 && resizeMax < Number.MAX_SAFE_INTEGER ? resizeMax : Math.max(header.getSize(), RESIZE_UNBOUNDED_ARIA_MAX),
766
+ "aria-valuetext": t("data.table.resizeColumnValue", {
767
+ count: Math.round(header.getSize()),
768
+ size: formatNumber(Math.round(header.getSize()))
769
+ }),
770
+ "aria-label": t("data.table.resizeColumn", { name: headerLabel }),
771
+ tabIndex: 0,
772
+ "data-slot": "data-table-resize-handle",
773
+ onMouseDown: header.getResizeHandler(),
774
+ onTouchStart: header.getResizeHandler(),
775
+ onKeyDown: (event) => handleResizeKeyDown(event, header.column),
776
+ onDoubleClick: () => handleResizeDoubleClick(header.column),
777
+ className: cn(
778
+ // #51: the hit box is a literal 24px (clamped to half
779
+ // the header cell so it can never overlap a neighbour,
780
+ // even at `minSize=20`) rather than the `w-2` Tailwind
781
+ // spacing-scale utility. `w-2` compiles to
782
+ // `calc(var(--spacing) * 2)`, and `--spacing` is what
783
+ // `data-density="compact"` rescales — so the old 8px
784
+ // hit box shrank further under compact density
785
+ // (~7.1px). A literal px value is density-independent
786
+ // by construction, which is the actual defect the
787
+ // maintainer's review corrected (NOT `--type-factor`,
788
+ // which this handle never used). Do not widen via
789
+ // overhang into the neighbouring cell instead — on the
790
+ // last column that lands inside the `overflow-auto`
791
+ // box (#330 false positive) and a pinned neighbour
792
+ // paints over/hit-tests away the extra area.
793
+ "absolute inset-y-0 end-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
794
+ // #51: the focus ring moves to the `after:` pseudo-
795
+ // element (the drawn seam) rather than the box itself
796
+ // — the box is now a 24px hit target, and a 24px focus
797
+ // rectangle would replace the deliberately slim ring
798
+ // already reviewed/approved as the #12 a11y fix
799
+ // (da9b29e). `focus-visible:after:*` targets the
800
+ // pseudo-element the same way `hover:after:w-2` /
801
+ // `focus-visible:after:w-2` below already do.
802
+ "focus-visible:outline-none",
803
+ // a11y fix (#12 review, blocking): this handle is the
804
+ // SOLE boundary between two adjacent header cells once
805
+ // resizing is on — no fill/elevation change separates
806
+ // them otherwise — so per the border/border-strong
807
+ // decision test (styling-and-tokens.md) it needs a
808
+ // rung that clears WCAG 1.4.11's 3:1 on its OWN, in
809
+ // EVERY state, including rest (a control with no
810
+ // affordance until hover is unusable without a
811
+ // pointer). `border-strong` measures only 2.86-2.96:1
812
+ // against this `bg-surface-muted` header — that rung
813
+ // is guaranteed only vs `--card`/`--background`, not a
814
+ // same-tone surface, which is the exact trap the rule
815
+ // warns about. `muted-foreground` is guaranteed AA
816
+ // text contrast against `--surface-muted`
817
+ // (TEXT_SURFACES), so it clears the 3:1 non-text
818
+ // minimum with wide margin (measured ~5.3-6.4:1 in
819
+ // both themes, unaffected by density) and is already
820
+ // the header's own label color. A slim persistent
821
+ // `after:` seam (not just a hover reveal) gives the
822
+ // real resting boundary; hover/focus widen the drawn
823
+ // seam to 8px (`after:w-2`) using the same compliant
824
+ // color — a separate width from the 24px pointer hit
825
+ // box below (#51), which the seam does not fill.
826
+ // Dragging keeps the pre-existing full-fill
827
+ // `bg-primary` treatment — that is a drag AFFORDANCE,
828
+ // not a focus indicator, and it is redundant with the
829
+ // pointer capture, so it is out of scope here. The
830
+ // keyboard focus indicator on both branches is the
831
+ // shared compound one (#67), applied to the drawn seam
832
+ // via `focus-visible:after:focus-ring-static`: the
833
+ // element itself is a 24px transparent hit box, so
834
+ // ringing IT would ring nothing a user can see.
835
+ header.column.getIsResizing() ? "after:absolute after:inset-y-0 after:end-0 after:w-2 after:bg-primary after:content-[''] focus-visible:after:focus-ring-static" : "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"
836
+ )
837
+ }
838
+ )
839
+ ]
840
+ },
841
+ header.id
842
+ );
843
+ })
844
+ ] }, headerGroup.id))
422
845
  }
423
846
  );
424
847
  }
@@ -438,19 +861,18 @@ function DataTableInner({
438
861
  function rowActionName(row) {
439
862
  const explicit = rowActionLabel?.(row);
440
863
  if (explicit) return explicit;
441
- const firstValue = row.getVisibleCells()[0]?.getValue();
442
- if (typeof firstValue === "string" && firstValue.trim() !== "") return firstValue;
443
- if (typeof firstValue === "number") return String(firstValue);
864
+ const name = firstDataCellValue(row);
865
+ if (name !== void 0) return name;
444
866
  return t("data.table.rowAction");
445
867
  }
446
- function renderRow(row, rowIndex, extras) {
868
+ function renderRow(row, rowIndex, extras, dragHandle) {
447
869
  const clickable = Boolean(onRowClick);
448
870
  function handleRowClick(event) {
449
871
  if (isInteractiveEventTarget(event.target)) return;
450
872
  if (isActiveTextSelection()) return;
451
873
  onRowClick?.(row, event);
452
874
  }
453
- return /* @__PURE__ */ jsx(
875
+ return /* @__PURE__ */ jsxs(
454
876
  "tr",
455
877
  {
456
878
  "data-state": row.getIsSelected() ? "selected" : void 0,
@@ -462,6 +884,15 @@ function DataTableInner({
462
884
  // pair already collapses toward ~0ms via --motion-factor when the user
463
885
  // or OS asks for reduced motion, matching the header sort button.
464
886
  "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
887
+ // #13: the dragged row's live `transform` (set inline via `extras.style`,
888
+ // see `SortableDataRow`) is what actually MOVES it — this class only
889
+ // makes that movement glide instead of snapping, through the gated
890
+ // duration/ease utilities (never a raw ms/ease value —
891
+ // quality-gates.md "Motion-tokened") with a reduced-motion
892
+ // neutralizer. Raising the dragged row's stacking + opacity is a
893
+ // colour/composite-only cue, so it isn't gated by the same rule.
894
+ dragHandle && "relative transition-transform duration-base ease-standard motion-reduce:transition-none",
895
+ dragHandle?.isDragging && "z-20 opacity-90 shadow-md",
465
896
  // Named group (#333) so a PINNED cell can re-apply the row's hover /
466
897
  // selected wash on top of its own opaque fill — only CSS knows the
467
898
  // pointer is over a sibling cell. Purely a selector hook: `group/row`
@@ -474,58 +905,146 @@ function DataTableInner({
474
905
  // `has-[[data-slot=…]:focus-visible]` pattern as InputGroup) so the
475
906
  // ring paints on the ROW the user is about to activate, even though
476
907
  // focus lives on the sr-only control inside it.
477
- clickable && "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:outline-2 has-[[data-slot=data-table-row-action]:focus-visible]:-outline-offset-2 has-[[data-slot=data-table-row-action]:focus-visible]:outline-ring",
908
+ clickable && "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:focus-ring-static-inset",
478
909
  rowClassName?.(row)
479
910
  ),
480
911
  ...extras,
481
- children: row.getVisibleCells().map((cell, cellIndex) => {
482
- const geometry = pinnedCellGeometry(cell.column);
483
- return /* @__PURE__ */ jsxs(
484
- "td",
912
+ children: [
913
+ dragHandle?.activator && /* @__PURE__ */ jsx("td", { className: "w-10 px-3 py-2 align-middle", children: /* @__PURE__ */ jsx(
914
+ "button",
485
915
  {
486
- "data-pinned": geometry?.pinned ?? void 0,
487
- style: geometry?.style,
916
+ type: "button",
917
+ ref: dragHandle.activator.setActivatorNodeRef,
918
+ "data-slot": "data-table-row-drag-handle",
919
+ "aria-label": t("data.table.reorderHandle", { name: rowActionName(row) }),
488
920
  className: cn(
489
- "px-3 py-2 align-middle",
490
- // z-10: above the normal (unpositioned) cells it scrolls over,
491
- // below the sticky header row (z-20) and the pinned corner (z-30).
492
- geometry && "sticky z-10",
493
- geometry && pinnedCellFillClass(rowIndex),
494
- // Separate cn() argument — see pinnedCellGeometry's edgeClass.
495
- geometry?.edgeClass
921
+ "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",
922
+ dragHandle.isDragging && "text-foreground"
496
923
  ),
497
- children: [
498
- clickable && cellIndex === 0 && /* @__PURE__ */ jsx(
499
- "button",
500
- {
501
- type: "button",
502
- "data-slot": "data-table-row-action",
503
- className: "sr-only",
504
- onClick: (event) => onRowClick?.(row, event),
505
- children: rowActionName(row)
506
- }
924
+ ...dragHandle.activator.attributes,
925
+ ...dragHandle.activator.listeners,
926
+ children: /* @__PURE__ */ jsx(GripVertical, { "aria-hidden": "true", className: "size-4" })
927
+ }
928
+ ) }),
929
+ row.getVisibleCells().map((cell, cellIndex) => {
930
+ const geometry = pinnedCellGeometry(cell.column);
931
+ const resizeStyle = enableColumnResizing ? resizeWidthStyle(cell.column.getSize()) : void 0;
932
+ return /* @__PURE__ */ jsxs(
933
+ "td",
934
+ {
935
+ "data-pinned": geometry?.pinned ?? void 0,
936
+ style: geometry?.style ?? resizeStyle,
937
+ className: cn(
938
+ "px-3 py-2 align-middle",
939
+ // #69: same numeric-column seam as the header — see
940
+ // `numericColumnClasses`.
941
+ numericColumnClasses(cell.column.columnDef.meta),
942
+ // z-10: above the normal (unpositioned) cells it scrolls over,
943
+ // below the sticky header row (z-20) and the pinned corner (z-30).
944
+ geometry && "sticky z-10",
945
+ geometry && pinnedCellFillClass(rowIndex),
946
+ // Separate cn() argument — see pinnedCellGeometry's edgeClass.
947
+ geometry?.edgeClass
507
948
  ),
508
- flexRender(cell.column.columnDef.cell, cell.getContext())
509
- ]
510
- },
511
- cell.id
512
- );
513
- })
949
+ children: [
950
+ clickable && cellIndex === 0 && /* @__PURE__ */ jsx(
951
+ "button",
952
+ {
953
+ type: "button",
954
+ "data-slot": "data-table-row-action",
955
+ className: "sr-only focus-visible:outline-none",
956
+ onClick: (event) => onRowClick?.(row, event),
957
+ children: rowActionName(row)
958
+ }
959
+ ),
960
+ flexRender(cell.column.columnDef.cell, cell.getContext())
961
+ ]
962
+ },
963
+ cell.id
964
+ );
965
+ })
966
+ ]
514
967
  },
515
968
  row.id
516
969
  );
517
970
  }
518
971
  function renderSkeletonBody(count) {
519
- return Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsx("tr", { "aria-hidden": "true", className: rowSeparationClass(i), children: Array.from({ length: colCount }).map((_2, j) => /* @__PURE__ */ jsx("td", { className: "px-3 py-2 align-middle", children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }) }, j)) }, `skeleton-${i}`));
972
+ const visibleColumns = table.getVisibleLeafColumns();
973
+ return Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsxs("tr", { "aria-hidden": "true", className: rowSeparationClass(i), children: [
974
+ hasGripColumn && /* @__PURE__ */ jsx("td", { className: "w-10 px-3 py-2 align-middle", children: /* @__PURE__ */ jsx(Skeleton, { className: "size-4" }) }),
975
+ visibleColumns.map((column) => /* @__PURE__ */ jsx(
976
+ "td",
977
+ {
978
+ className: cn("px-3 py-2 align-middle", numericColumnClasses(column.columnDef.meta)),
979
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" })
980
+ },
981
+ column.id
982
+ ))
983
+ ] }, `skeleton-${i}`));
520
984
  }
521
985
  function renderEmptyBody() {
522
- return /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: colCount, className: "h-24 px-3 text-center text-muted-foreground", children: emptyMessage }) });
986
+ return /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx(
987
+ "td",
988
+ {
989
+ colSpan: colCount + (hasGripColumn ? 1 : 0),
990
+ className: "h-24 px-3 text-center text-muted-foreground",
991
+ children: emptyMessage
992
+ }
993
+ ) });
523
994
  }
524
995
  function renderTbodyNormal() {
525
996
  if (showSkeletons) {
526
997
  return /* @__PURE__ */ jsx("tbody", { children: renderSkeletonBody(skeletonRowCount) });
527
998
  }
528
- return /* @__PURE__ */ jsx("tbody", { children: showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i)) });
999
+ if (showEmpty) {
1000
+ return /* @__PURE__ */ jsx("tbody", { children: renderEmptyBody() });
1001
+ }
1002
+ if (!rowReorderActive) {
1003
+ return /* @__PURE__ */ jsx("tbody", { children: rows.map((row, i) => renderRow(row, i)) });
1004
+ }
1005
+ return /* @__PURE__ */ jsx(
1006
+ SortableContext,
1007
+ {
1008
+ items: rows.map((r) => getReorderRowId(r)),
1009
+ strategy: verticalListSortingStrategy,
1010
+ children: /* @__PURE__ */ jsx("tbody", { children: rows.map((row, i) => /* @__PURE__ */ jsx(
1011
+ SortableDataRow,
1012
+ {
1013
+ id: getReorderRowId(row),
1014
+ attributesOverride: {
1015
+ // #98: dnd-kit's own `roleDescription: 'sortable'` default is
1016
+ // hardcoded English; override it with the localized value in
1017
+ // BOTH handle modes — `role` stays row-mode-only (see the
1018
+ // `attributesOverride` prop doc above).
1019
+ roleDescription: t("data.table.reorderRoleDescription"),
1020
+ ...rowReorderHandle === "row" ? { role: "row" } : null
1021
+ },
1022
+ children: ({ setNodeRef, setActivatorNodeRef, attributes, listeners, isDragging, style }) => renderRow(
1023
+ row,
1024
+ i,
1025
+ {
1026
+ ref: setNodeRef,
1027
+ style,
1028
+ // `aria-pressed` is a `DraggableAttributes` field meant for a
1029
+ // real `<button>` activator; spread onto a `<tr role="row">`
1030
+ // (row-handle mode) it fails axe's `aria-allowed-attr` (that
1031
+ // ARIA state is not permitted on the `row` role), so strip it
1032
+ // here rather than exempt it downstream.
1033
+ ...rowReorderHandle === "row" ? (() => {
1034
+ const { "aria-pressed": _ariaPressed, ...rowAttributes } = attributes;
1035
+ return { ...rowAttributes, ...listeners };
1036
+ })() : {}
1037
+ },
1038
+ {
1039
+ isDragging,
1040
+ activator: rowReorderHandle === "cell" ? { setActivatorNodeRef, attributes, listeners } : void 0
1041
+ }
1042
+ )
1043
+ },
1044
+ getReorderRowId(row)
1045
+ )) })
1046
+ }
1047
+ );
529
1048
  }
530
1049
  function renderTbodyVirtualized() {
531
1050
  if (showSkeletons) {
@@ -594,7 +1113,7 @@ function DataTableInner({
594
1113
  tabIndex: 0,
595
1114
  "aria-label": t("data.table.scrollRegion"),
596
1115
  "aria-busy": loading || void 0,
597
- className: "relative overflow-auto rounded-lg border bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1116
+ className: "relative overflow-auto rounded-lg border bg-card focus-ring",
598
1117
  style: { maxHeight: maxBodyHeight, ...pinnedScrollPadding },
599
1118
  children: [
600
1119
  loading && rows.length > 0 && /* @__PURE__ */ jsxs(
@@ -627,7 +1146,7 @@ function DataTableInner({
627
1146
  )
628
1147
  ] });
629
1148
  }
630
- return /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
1149
+ const nonVirtualizedContent = /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
631
1150
  toolbar ? toolbar(table) : null,
632
1151
  /* @__PURE__ */ jsxs(
633
1152
  "div",
@@ -655,7 +1174,7 @@ function DataTableInner({
655
1174
  tabIndex: scrollOverflows ? 0 : void 0,
656
1175
  "aria-label": scrollOverflows ? t("data.table.scrollRegion") : void 0,
657
1176
  onScroll: updateScrollAffordance,
658
- className: "overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1177
+ className: "overflow-auto rounded-lg focus-ring-inset",
659
1178
  style: hasLeftPinned || hasRightPinned ? pinnedScrollPadding : void 0,
660
1179
  children: /* @__PURE__ */ jsxs("table", { "aria-busy": loading || void 0, className: "w-full caption-bottom text-body", children: [
661
1180
  captionElement,
@@ -685,6 +1204,38 @@ function DataTableInner({
685
1204
  ),
686
1205
  renderPagination()
687
1206
  ] });
1207
+ if (!rowReorderActive) return nonVirtualizedContent;
1208
+ return /* @__PURE__ */ jsxs(
1209
+ DndContext,
1210
+ {
1211
+ sensors: reorderSensors,
1212
+ collisionDetection: closestCenter,
1213
+ onDragStart: handleRowDragStart,
1214
+ onDragOver: handleRowDragOver,
1215
+ onDragEnd: handleRowDragEnd,
1216
+ onDragCancel: handleRowDragCancel,
1217
+ accessibility: {
1218
+ announcements: silentDragAnnouncements,
1219
+ // #98: dnd-kit's own hidden keyboard-instructions node is hardcoded
1220
+ // English (`defaultScreenReaderInstructions`) unless overridden here.
1221
+ screenReaderInstructions: { draggable: t("data.table.reorderInstructions") }
1222
+ },
1223
+ children: [
1224
+ nonVirtualizedContent,
1225
+ /* @__PURE__ */ jsx(
1226
+ "div",
1227
+ {
1228
+ role: "status",
1229
+ "aria-live": "polite",
1230
+ "aria-atomic": "true",
1231
+ "data-slot": "data-table-reorder-live-region",
1232
+ className: "sr-only",
1233
+ children: reorderLiveMessage
1234
+ }
1235
+ )
1236
+ ]
1237
+ }
1238
+ );
688
1239
  }
689
1240
  var DataTableWithRef = forwardRef(DataTableInner);
690
1241
 
@@ -732,7 +1283,7 @@ function SearchInput({
732
1283
  type: "button",
733
1284
  onClick: () => onValueChange(""),
734
1285
  "aria-label": "Clear search",
735
- className: "absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance",
1286
+ className: "absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance",
736
1287
  children: /* @__PURE__ */ jsx2(
737
1288
  "svg",
738
1289
  {
@@ -764,8 +1315,31 @@ function FilterBar({ children, actions, className }) {
764
1315
  ] });
765
1316
  }
766
1317
 
767
- // src/facet-filter/facet-filter.tsx
1318
+ // src/filter-bar/filter-chip.tsx
768
1319
  import { forwardRef as forwardRef2 } from "react";
1320
+ import {
1321
+ FilterChip as BaseFilterChip,
1322
+ useLocale as useLocale2
1323
+ } from "@elabs-ai/components-ui";
1324
+ import { jsx as jsx4 } from "react/jsx-runtime";
1325
+ var FilterChip = forwardRef2(function FilterChip2({ label, count, countLabel, ...props }, ref) {
1326
+ const { formatNumber } = useLocale2();
1327
+ const countText = count === void 0 ? void 0 : countLabel ? `${countLabel} ${formatNumber(count)}` : formatNumber(count);
1328
+ const { trailing: _ignoredTrailing, ...restProps } = props;
1329
+ return /* @__PURE__ */ jsx4(
1330
+ BaseFilterChip,
1331
+ {
1332
+ ref,
1333
+ "data-slot": "filter-chip",
1334
+ label,
1335
+ ...restProps,
1336
+ trailing: countText
1337
+ }
1338
+ );
1339
+ });
1340
+
1341
+ // src/facet-filter/facet-filter.tsx
1342
+ import { forwardRef as forwardRef3 } from "react";
769
1343
  import {
770
1344
  Badge,
771
1345
  Button as Button2,
@@ -774,11 +1348,13 @@ import {
774
1348
  DropdownMenuItem,
775
1349
  DropdownMenuLabel,
776
1350
  DropdownMenuSeparator,
777
- DropdownMenuTrigger
1351
+ DropdownMenuTrigger,
1352
+ useLocale as useLocale3
778
1353
  } from "@elabs-ai/components-ui";
779
1354
  import { cn as cn4 } from "@elabs-ai/components-ui/lib/cn";
780
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
781
- var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected, onSelectedChange, className, ...props }, ref) {
1355
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1356
+ var FacetFilter = forwardRef3(function FacetFilter2({ title, options, selected, onSelectedChange, className, ...props }, ref) {
1357
+ const { t } = useLocale3();
782
1358
  const selectedSet = new Set(selected);
783
1359
  const toggle = (value) => {
784
1360
  const next = new Set(selectedSet);
@@ -787,9 +1363,9 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
787
1363
  onSelectedChange([...next]);
788
1364
  };
789
1365
  return /* @__PURE__ */ jsxs4(DropdownMenu, { children: [
790
- /* @__PURE__ */ jsx4(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(Button2, { ref, variant: "outline", className: cn4("border-dashed", className), ...props, children: [
1366
+ /* @__PURE__ */ jsx5(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(Button2, { ref, variant: "outline", className: cn4("border-dashed", className), ...props, children: [
791
1367
  title,
792
- selected.length > 0 ? /* @__PURE__ */ jsx4(
1368
+ selected.length > 0 ? /* @__PURE__ */ jsx5(
793
1369
  Badge,
794
1370
  {
795
1371
  variant: "secondary",
@@ -799,7 +1375,7 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
799
1375
  ) : null
800
1376
  ] }) }),
801
1377
  /* @__PURE__ */ jsxs4(DropdownMenuContent, { className: "min-w-[12rem]", children: [
802
- /* @__PURE__ */ jsx4(DropdownMenuLabel, { children: title }),
1378
+ /* @__PURE__ */ jsx5(DropdownMenuLabel, { children: title }),
803
1379
  options.map((opt) => {
804
1380
  const checked = selectedSet.has(opt.value);
805
1381
  return /* @__PURE__ */ jsxs4(
@@ -810,7 +1386,7 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
810
1386
  toggle(opt.value);
811
1387
  },
812
1388
  children: [
813
- /* @__PURE__ */ jsx4(
1389
+ /* @__PURE__ */ jsx5(
814
1390
  "span",
815
1391
  {
816
1392
  "aria-hidden": "true",
@@ -825,8 +1401,8 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
825
1401
  );
826
1402
  }),
827
1403
  selected.length > 0 ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
828
- /* @__PURE__ */ jsx4(DropdownMenuSeparator, {}),
829
- /* @__PURE__ */ jsx4(DropdownMenuItem, { onSelect: () => onSelectedChange([]), children: "Clear filters" })
1404
+ /* @__PURE__ */ jsx5(DropdownMenuSeparator, {}),
1405
+ /* @__PURE__ */ jsx5(DropdownMenuItem, { onSelect: () => onSelectedChange([]), children: t("data.facetFilter.clearFilters") })
830
1406
  ] }) : null
831
1407
  ] })
832
1408
  ] });
@@ -835,7 +1411,7 @@ FacetFilter.displayName = "FacetFilter";
835
1411
 
836
1412
  // src/column-picker/column-picker.tsx
837
1413
  import "@tanstack/react-table";
838
- import { forwardRef as forwardRef3 } from "react";
1414
+ import { forwardRef as forwardRef4 } from "react";
839
1415
  import {
840
1416
  Button as Button3,
841
1417
  DropdownMenu as DropdownMenu2,
@@ -843,17 +1419,19 @@ import {
843
1419
  DropdownMenuItem as DropdownMenuItem2,
844
1420
  DropdownMenuLabel as DropdownMenuLabel2,
845
1421
  DropdownMenuSeparator as DropdownMenuSeparator2,
846
- DropdownMenuTrigger as DropdownMenuTrigger2
1422
+ DropdownMenuTrigger as DropdownMenuTrigger2,
1423
+ useLocale as useLocale4
847
1424
  } from "@elabs-ai/components-ui";
848
1425
  import { cn as cn5 } from "@elabs-ai/components-ui/lib/cn";
849
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1426
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
850
1427
  function ColumnPickerInner({ table, label = "Columns", className, ...props }, ref) {
1428
+ const { t } = useLocale4();
851
1429
  const columns = table.getAllColumns().filter((c) => c.getCanHide());
852
1430
  return /* @__PURE__ */ jsxs5(DropdownMenu2, { children: [
853
- /* @__PURE__ */ jsx5(DropdownMenuTrigger2, { asChild: true, children: /* @__PURE__ */ jsx5(Button3, { ref, variant: "outline", size: "sm", className: cn5(className), ...props, children: label }) }),
1431
+ /* @__PURE__ */ jsx6(DropdownMenuTrigger2, { asChild: true, children: /* @__PURE__ */ jsx6(Button3, { ref, variant: "outline", size: "sm", className: cn5(className), ...props, children: label }) }),
854
1432
  /* @__PURE__ */ jsxs5(DropdownMenuContent2, { align: "end", className: "min-w-[12rem]", children: [
855
- /* @__PURE__ */ jsx5(DropdownMenuLabel2, { children: "Toggle columns" }),
856
- /* @__PURE__ */ jsx5(DropdownMenuSeparator2, {}),
1433
+ /* @__PURE__ */ jsx6(DropdownMenuLabel2, { children: t("data.columnPicker.toggleColumns") }),
1434
+ /* @__PURE__ */ jsx6(DropdownMenuSeparator2, {}),
857
1435
  columns.map((column) => /* @__PURE__ */ jsxs5(
858
1436
  DropdownMenuItem2,
859
1437
  {
@@ -862,7 +1440,7 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
862
1440
  column.toggleVisibility(!column.getIsVisible());
863
1441
  },
864
1442
  children: [
865
- /* @__PURE__ */ jsx5(
1443
+ /* @__PURE__ */ jsx6(
866
1444
  "span",
867
1445
  {
868
1446
  "aria-hidden": "true",
@@ -870,7 +1448,7 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
870
1448
  children: column.getIsVisible() ? "\u2713" : ""
871
1449
  }
872
1450
  ),
873
- /* @__PURE__ */ jsx5("span", { className: "capitalize", children: column.id })
1451
+ /* @__PURE__ */ jsx6("span", { className: "capitalize", children: column.id })
874
1452
  ]
875
1453
  },
876
1454
  column.id
@@ -879,7 +1457,7 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
879
1457
  ] });
880
1458
  }
881
1459
  ColumnPickerInner.displayName = "ColumnPicker";
882
- var ColumnPicker = forwardRef3(ColumnPickerInner);
1460
+ var ColumnPicker = forwardRef4(ColumnPickerInner);
883
1461
 
884
1462
  // src/to-csv.ts
885
1463
  import { downloadBlob } from "@elabs-ai/components-ui";
@@ -926,7 +1504,9 @@ export {
926
1504
  DataTableWithRef as DataTable,
927
1505
  FacetFilter,
928
1506
  FilterBar,
1507
+ FilterChip,
929
1508
  SearchInput,
1509
+ createSelectionColumn,
930
1510
  downloadCsv,
931
1511
  toCsv
932
1512
  };