@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/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;
@@ -24,6 +51,7 @@ function isActiveTextSelection() {
24
51
  return window.getSelection()?.type === "Range";
25
52
  }
26
53
  var PINNED_SEAM_CLASS = "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
54
+ var COLUMN_DIVIDER_CLASS = "border-e border-rule last:border-e-0";
27
55
  function unsizedColumnIds(defs) {
28
56
  const out = /* @__PURE__ */ new Set();
29
57
  const walk = (list) => {
@@ -42,6 +70,78 @@ function unsizedColumnIds(defs) {
42
70
  walk(defs);
43
71
  return out;
44
72
  }
73
+ function resizeWidthStyle(size) {
74
+ return { width: size, minWidth: size, maxWidth: size };
75
+ }
76
+ function firstDataCellValue(row) {
77
+ for (const cell of row.getVisibleCells()) {
78
+ if (!cell.column.accessorFn) continue;
79
+ const value = cell.getValue();
80
+ if (typeof value === "string" && value.trim() !== "") return value;
81
+ if (typeof value === "number") return String(value);
82
+ }
83
+ return void 0;
84
+ }
85
+ function SelectAllHeaderCell({ table }) {
86
+ const { t } = useLocale();
87
+ const allSelected = table.getIsAllPageRowsSelected();
88
+ const someSelected = table.getIsSomePageRowsSelected();
89
+ return /* @__PURE__ */ jsx(
90
+ Checkbox,
91
+ {
92
+ "data-slot": "data-table-select-all",
93
+ checked: allSelected ? true : someSelected ? "indeterminate" : false,
94
+ onCheckedChange: (checked) => table.toggleAllPageRowsSelected(checked === true),
95
+ "aria-label": t("data.table.selectAllRows")
96
+ }
97
+ );
98
+ }
99
+ function SelectRowCell({ row }) {
100
+ const { t } = useLocale();
101
+ const name = firstDataCellValue(row);
102
+ return /* @__PURE__ */ jsx(
103
+ Checkbox,
104
+ {
105
+ "data-slot": "data-table-select-cell",
106
+ checked: row.getIsSelected(),
107
+ disabled: !row.getCanSelect(),
108
+ onCheckedChange: (checked) => row.toggleSelected(checked === true),
109
+ "aria-label": name ? t("data.table.selectRowNamed", { name }) : t("data.table.selectRow")
110
+ }
111
+ );
112
+ }
113
+ function createSelectionColumn() {
114
+ return {
115
+ id: "select",
116
+ size: 40,
117
+ enableSorting: false,
118
+ enableHiding: false,
119
+ header: ({ table }) => (
120
+ // #11 C1: `toggleAllPageRowsSelected` wipes-then-sets on every row when
121
+ // `enableMultiRowSelection` is off (TanStack's `mutateRowIsSelected`), so
122
+ // a select-all header under single-select leaves only the LAST row
123
+ // selected and pins the header at indeterminate forever. Suppress it.
124
+ table.options.enableMultiRowSelection === false ? null : /* @__PURE__ */ jsx(SelectAllHeaderCell, { table })
125
+ ),
126
+ cell: ({ row }) => /* @__PURE__ */ jsx(SelectRowCell, { row })
127
+ };
128
+ }
129
+ function SortableDataRow({
130
+ id,
131
+ disabled,
132
+ attributesOverride,
133
+ children
134
+ }) {
135
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging } = useSortable({ id, disabled, transition: null, attributes: attributesOverride });
136
+ return /* @__PURE__ */ jsx(Fragment, { children: children({
137
+ setNodeRef,
138
+ setActivatorNodeRef,
139
+ attributes,
140
+ listeners,
141
+ isDragging,
142
+ style: { transform: CSS.Transform.toString(transform) }
143
+ }) });
144
+ }
45
145
  function DataTableInner({
46
146
  columns,
47
147
  data,
@@ -63,6 +163,15 @@ function DataTableInner({
63
163
  onPaginationChange: onPaginationChangeProp,
64
164
  columnPinning: columnPinningProp,
65
165
  onColumnPinningChange: onColumnPinningChangeProp,
166
+ enableColumnResizing = false,
167
+ columnResizeMode = "onChange",
168
+ columnSizing: columnSizingProp,
169
+ onColumnSizingChange: onColumnSizingChangeProp,
170
+ rowSelection: rowSelectionProp,
171
+ onRowSelectionChange: onRowSelectionChangeProp,
172
+ enableRowSelection,
173
+ enableMultiRowSelection,
174
+ getRowId,
66
175
  // Saved views rehydration
67
176
  initialView,
68
177
  // Server-side model
@@ -81,6 +190,11 @@ function DataTableInner({
81
190
  overscan = 8,
82
191
  maxBodyHeight = "32rem",
83
192
  zebra = true,
193
+ columnDividers = false,
194
+ // Row drag-reorder (#13)
195
+ enableRowReorder = false,
196
+ onRowReorder,
197
+ rowReorderHandle = "cell",
84
198
  onRowClick,
85
199
  rowActionLabel,
86
200
  rowClassName,
@@ -89,13 +203,15 @@ function DataTableInner({
89
203
  className,
90
204
  ...rest
91
205
  }, ref) {
92
- const { t } = useLocale();
206
+ const { t, dir, formatNumber } = useLocale();
93
207
  const isSortingControlled = sortingProp !== void 0;
94
208
  const isColumnVisibilityControlled = columnVisibilityProp !== void 0;
95
209
  const isColumnFiltersControlled = columnFiltersProp !== void 0;
96
210
  const isPaginationControlled = paginationProp !== void 0;
97
211
  const isFilterControlled = globalFilterProp !== void 0;
98
212
  const isColumnPinningControlled = columnPinningProp !== void 0;
213
+ const isColumnSizingControlled = columnSizingProp !== void 0;
214
+ const isRowSelectionControlled = rowSelectionProp !== void 0;
99
215
  const [internalSorting, setInternalSorting] = useState(
100
216
  () => initialView?.sorting ?? []
101
217
  );
@@ -117,12 +233,20 @@ function DataTableInner({
117
233
  const [internalColumnPinning, setInternalColumnPinning] = useState(
118
234
  () => initialView?.columnPinning ?? { left: [], right: [] }
119
235
  );
236
+ const [internalColumnSizing, setInternalColumnSizing] = useState(
237
+ () => initialView?.columnSizing ?? {}
238
+ );
239
+ const [internalRowSelection, setInternalRowSelection] = useState(
240
+ () => initialView?.rowSelection ?? {}
241
+ );
120
242
  const sorting = isSortingControlled ? sortingProp : internalSorting;
121
243
  const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility;
122
244
  const columnFilters = isColumnFiltersControlled ? columnFiltersProp : internalColumnFilters;
123
245
  const pagination = isPaginationControlled ? paginationProp : internalPagination;
124
246
  const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
125
247
  const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
248
+ const columnSizing = isColumnSizingControlled ? columnSizingProp : internalColumnSizing;
249
+ const rowSelection = isRowSelectionControlled ? rowSelectionProp : internalRowSelection;
126
250
  const sortingRef = useRef(sorting);
127
251
  sortingRef.current = sorting;
128
252
  const columnFiltersRef = useRef(columnFilters);
@@ -135,6 +259,10 @@ function DataTableInner({
135
259
  columnVisibilityRef.current = columnVisibility;
136
260
  const columnPinningRef = useRef(columnPinning);
137
261
  columnPinningRef.current = columnPinning;
262
+ const columnSizingRef = useRef(columnSizing);
263
+ columnSizingRef.current = columnSizing;
264
+ const rowSelectionRef = useRef(rowSelection);
265
+ rowSelectionRef.current = rowSelection;
138
266
  const warnedMissingRowCountRef = useRef(false);
139
267
  useEffect(() => {
140
268
  if (process.env.NODE_ENV !== "production" && manualPagination && rowCount === void 0 && pageCount === void 0 && !warnedMissingRowCountRef.current) {
@@ -144,6 +272,54 @@ function DataTableInner({
144
272
  );
145
273
  }
146
274
  }, [manualPagination, rowCount, pageCount]);
275
+ const warnedManualSelectionRef = useRef(false);
276
+ useEffect(() => {
277
+ if (process.env.NODE_ENV !== "production" && manualPagination && getRowId === void 0 && (isRowSelectionControlled || onRowSelectionChangeProp !== void 0) && !warnedManualSelectionRef.current) {
278
+ warnedManualSelectionRef.current = true;
279
+ console.warn(
280
+ '[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.'
281
+ );
282
+ }
283
+ }, [manualPagination, getRowId, isRowSelectionControlled, onRowSelectionChangeProp]);
284
+ const warnedReorderSortingRef = useRef(false);
285
+ useEffect(() => {
286
+ if (process.env.NODE_ENV !== "production" && enableRowReorder && sorting.length > 0 && !warnedReorderSortingRef.current) {
287
+ warnedReorderSortingRef.current = true;
288
+ console.warn(
289
+ "[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."
290
+ );
291
+ }
292
+ }, [enableRowReorder, sorting.length]);
293
+ const warnedReorderVirtualizedRef = useRef(false);
294
+ useEffect(() => {
295
+ if (process.env.NODE_ENV !== "production" && enableRowReorder && enableRowVirtualization && !warnedReorderVirtualizedRef.current) {
296
+ warnedReorderVirtualizedRef.current = true;
297
+ console.warn(
298
+ "[DataTable] `enableRowReorder` has no effect while `enableRowVirtualization` is set \u2014 the two are mutually exclusive. Virtualization wins; row reorder is disabled."
299
+ );
300
+ }
301
+ }, [enableRowReorder, enableRowVirtualization]);
302
+ const rowReorderActive = enableRowReorder && !enableRowVirtualization;
303
+ const hasGripColumn = rowReorderActive && rowReorderHandle === "cell";
304
+ const reorderSensors = useSensors(
305
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
306
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
307
+ );
308
+ const reorderIdentityMapRef = useRef(/* @__PURE__ */ new WeakMap());
309
+ const reorderIdentityCounterRef = useRef(0);
310
+ const reorderRepeatedPositions = useMemo(() => {
311
+ const repeats = /* @__PURE__ */ new Set();
312
+ if (!rowReorderActive) return repeats;
313
+ const seen = /* @__PURE__ */ new Set();
314
+ data.forEach((record, index) => {
315
+ if (record === null || typeof record !== "object") return;
316
+ if (seen.has(record)) repeats.add(index);
317
+ else seen.add(record);
318
+ });
319
+ return repeats;
320
+ }, [data, rowReorderActive]);
321
+ const [reorderLiveMessage, setReorderLiveMessage] = useState("");
322
+ const reorderLastAnnouncedPositionRef = useRef(null);
147
323
  function fireServerChange(overrides = {}) {
148
324
  if (!onServerChange) return;
149
325
  onServerChange({
@@ -172,13 +348,28 @@ function DataTableInner({
172
348
  function resolveColumnPinning(updater) {
173
349
  return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
174
350
  }
351
+ function resolveColumnSizing(updater) {
352
+ return typeof updater === "function" ? updater(columnSizingRef.current) : updater;
353
+ }
354
+ function resolveRowSelection(updater) {
355
+ return typeof updater === "function" ? updater(rowSelectionRef.current) : updater;
356
+ }
175
357
  const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
176
358
  const filteredRowModel = manualFiltering ? {} : { getFilteredRowModel: getFilteredRowModel() };
177
359
  const paginationRowModel = enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
178
360
  const table = useReactTable({
179
361
  data,
180
362
  columns,
181
- state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
363
+ state: {
364
+ sorting,
365
+ columnVisibility,
366
+ columnFilters,
367
+ globalFilter,
368
+ pagination,
369
+ columnPinning,
370
+ columnSizing,
371
+ rowSelection
372
+ },
182
373
  // Sorting
183
374
  onSortingChange: (updater) => {
184
375
  const next = resolveSorting(updater);
@@ -233,6 +424,38 @@ function DataTableInner({
233
424
  if (!isColumnPinningControlled) setInternalColumnPinning(next);
234
425
  onColumnPinningChangeProp?.(updater);
235
426
  },
427
+ // Column resizing (#12) — a LAYOUT slice, like column pinning: a column's
428
+ // width changes nothing the server would need to re-query, so this never
429
+ // fires onServerChange either. Routed through by BOTH the pointer path
430
+ // (TanStack's own `header.getResizeHandler()`, wired below) and the
431
+ // keyboard path (`handleResizeKeyDown`, via `table.setColumnSizing`) so
432
+ // the two input modes can never diverge in controlled/uncontrolled
433
+ // behaviour.
434
+ columnResizeMode,
435
+ // RTL fix (#12 review, P1): TanStack's pointer-drag math hardcodes LTR
436
+ // unless told otherwise — `deltaDirection = columnResizeDirection ===
437
+ // 'rtl' ? -1 : 1` internally — so under `dir="rtl"` (the resize handle's
438
+ // own edge already flips via `end-0`, see the `useLocale()` call above)
439
+ // dragging would otherwise move the column's width opposite the visible
440
+ // boundary. `handleResizeKeyDown` below mirrors this for the keyboard path.
441
+ columnResizeDirection: dir,
442
+ enableColumnResizing,
443
+ onColumnSizingChange: (updater) => {
444
+ const next = resolveColumnSizing(updater);
445
+ if (!isColumnSizingControlled) setInternalColumnSizing(next);
446
+ onColumnSizingChangeProp?.(updater);
447
+ },
448
+ // Row selection (#11) — also a LAYOUT/UI slice, so it never fires
449
+ // onServerChange: which rows are checked changes nothing the server
450
+ // would need to re-query.
451
+ onRowSelectionChange: (updater) => {
452
+ const next = resolveRowSelection(updater);
453
+ if (!isRowSelectionControlled) setInternalRowSelection(next);
454
+ onRowSelectionChangeProp?.(updater);
455
+ },
456
+ enableRowSelection,
457
+ enableMultiRowSelection,
458
+ getRowId,
236
459
  getCoreRowModel: getCoreRowModel(),
237
460
  ...sortedRowModel,
238
461
  ...filteredRowModel,
@@ -251,6 +474,83 @@ function DataTableInner({
251
474
  const colCount = table.getVisibleLeafColumns().length;
252
475
  const headerRowCount = table.getHeaderGroups().length;
253
476
  const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
477
+ function reorderRowName(id) {
478
+ const row = rows.find((r) => getReorderRowId(r) === id);
479
+ return row ? rowActionName(row) : id;
480
+ }
481
+ function reorderPosition(id) {
482
+ return rows.findIndex((r) => getReorderRowId(r) === id) + 1;
483
+ }
484
+ function getReorderRowId(row) {
485
+ if (getRowId) return row.id;
486
+ const original = row.original;
487
+ if (original !== null && typeof original === "object") {
488
+ const map = reorderIdentityMapRef.current;
489
+ let id = map.get(original);
490
+ if (id === void 0) {
491
+ id = `__reorder-${reorderIdentityCounterRef.current++}`;
492
+ map.set(original, id);
493
+ }
494
+ return reorderRepeatedPositions.has(row.index) ? `${id}__${row.index}` : id;
495
+ }
496
+ return row.id;
497
+ }
498
+ const silentDragAnnouncements = {
499
+ onDragStart: () => void 0,
500
+ onDragOver: () => void 0,
501
+ onDragEnd: () => void 0,
502
+ onDragCancel: () => void 0
503
+ };
504
+ function handleRowDragStart(event) {
505
+ const activeRowId = String(event.active.id);
506
+ reorderLastAnnouncedPositionRef.current = reorderPosition(activeRowId);
507
+ setReorderLiveMessage(t("data.table.reorderPickedUp", { name: reorderRowName(activeRowId) }));
508
+ }
509
+ function handleRowDragOver(event) {
510
+ const { active, over } = event;
511
+ if (!over) return;
512
+ const position = reorderPosition(String(over.id));
513
+ if (position === reorderLastAnnouncedPositionRef.current) return;
514
+ reorderLastAnnouncedPositionRef.current = position;
515
+ setReorderLiveMessage(
516
+ t("data.table.reorderMoved", {
517
+ name: reorderRowName(String(active.id)),
518
+ position,
519
+ total: rows.length
520
+ })
521
+ );
522
+ }
523
+ function handleRowDragCancel(event) {
524
+ const activeRowId = String(event.active.id);
525
+ setReorderLiveMessage(
526
+ t("data.table.reorderCancelled", {
527
+ name: reorderRowName(activeRowId),
528
+ position: reorderPosition(activeRowId),
529
+ total: rows.length
530
+ })
531
+ );
532
+ reorderLastAnnouncedPositionRef.current = null;
533
+ }
534
+ function handleRowDragEnd(event) {
535
+ const { active, over } = event;
536
+ const activeRowId = String(active.id);
537
+ setReorderLiveMessage(
538
+ t("data.table.reorderDropped", {
539
+ name: reorderRowName(activeRowId),
540
+ position: reorderPosition(String(over ? over.id : active.id)),
541
+ total: rows.length
542
+ })
543
+ );
544
+ reorderLastAnnouncedPositionRef.current = null;
545
+ if (!over || active.id === over.id) return;
546
+ const movedRow = rows.find((r) => getReorderRowId(r) === activeRowId);
547
+ const targetRow = rows.find((r) => getReorderRowId(r) === String(over.id));
548
+ if (!movedRow || !targetRow) return;
549
+ const from = movedRow.index;
550
+ const to = targetRow.index;
551
+ if (from < 0 || from >= data.length || to < 0 || to >= data.length) return;
552
+ onRowReorder?.(from, to, movedRow.original);
553
+ }
254
554
  const hasLeftPinned = (columnPinning.left?.length ?? 0) > 0;
255
555
  const hasRightPinned = (columnPinning.right?.length ?? 0) > 0;
256
556
  const pinnedScrollPadding = {
@@ -298,12 +598,33 @@ function DataTableInner({
298
598
  // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
299
599
  // seam pixel read `143,143,143` (light `--border-strong`) at
300
600
  // 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
601
+ // scrolled, in every theme and on both edges. So the one cue vanished
302
602
  // exactly when the freeze was doing something. The `::after` lives in the
303
603
  // sticky cell's own stacking context, so it moves with it.
304
604
  edgeClass: pinned === "left" ? column.getIsLastColumn("left") ? PINNED_SEAM_CLASS + " after:end-0" : "" : column.getIsFirstColumn("right") ? PINNED_SEAM_CLASS + " after:start-0" : ""
305
605
  };
306
606
  }
607
+ const RESIZE_STEP = 10;
608
+ const RESIZE_UNBOUNDED_ARIA_MAX = 2e3;
609
+ function handleResizeKeyDown(event, column) {
610
+ let delta = 0;
611
+ if (event.key === "ArrowRight") delta = RESIZE_STEP;
612
+ else if (event.key === "ArrowLeft") delta = -RESIZE_STEP;
613
+ else return;
614
+ event.preventDefault();
615
+ if (dir === "rtl") delta = -delta;
616
+ const minSize = column.columnDef.minSize ?? 20;
617
+ const maxSize = column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
618
+ const nextSize = Math.min(maxSize, Math.max(minSize, column.getSize() + delta));
619
+ table.setColumnSizing((old) => ({ ...old, [column.id]: nextSize }));
620
+ }
621
+ function handleResizeDoubleClick(column) {
622
+ table.setColumnSizing((old) => {
623
+ if (!(column.id in old)) return old;
624
+ const { [column.id]: _removed, ...rest2 } = old;
625
+ return rest2;
626
+ });
627
+ }
307
628
  const scrollRef = useRef(null);
308
629
  const virtualizer = useVirtualizer({
309
630
  count: enableRowVirtualization ? rows.length : 0,
@@ -356,101 +677,210 @@ function DataTableInner({
356
677
  // delta: nothing else in the table sits between those rungs.
357
678
  sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60"
358
679
  ),
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))
680
+ children: table.getHeaderGroups().map((headerGroup, groupIndex) => /* @__PURE__ */ jsxs("tr", { "aria-rowindex": withRowIndex ? groupIndex + 1 : void 0, children: [
681
+ 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"),
682
+ headerGroup.headers.map((header) => {
683
+ const geometry = pinnedCellGeometry(header.column);
684
+ const canSort = header.column.getCanSort();
685
+ const sorted = header.column.getIsSorted();
686
+ const headerLabel = typeof header.column.columnDef.header === "string" ? header.column.columnDef.header : header.column.id;
687
+ const sortStateLabel = sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
688
+ const SortIcon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
689
+ const resizeStyle = enableColumnResizing ? resizeWidthStyle(header.getSize()) : void 0;
690
+ const canResize = enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
691
+ const resizeMax = header.column.columnDef.maxSize;
692
+ return /* @__PURE__ */ jsxs(
693
+ "th",
694
+ {
695
+ scope: "col",
696
+ "aria-sort": canSort ? sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none" : void 0,
697
+ "data-pinned": geometry?.pinned ?? void 0,
698
+ style: geometry?.style ?? resizeStyle,
699
+ className: cn(
700
+ // Same `px-3` the body `<td>` uses (below) deliberately
701
+ // NOT split into `ps-3`/`pe-3` for a resize-handle
702
+ // override (round-1 briefly did this, see the round-2
703
+ // note on `numericColumnClasses`): the header's padding
704
+ // must stay byte-identical to the body's so an
705
+ // end-aligned numeric column's header lines up with its
706
+ // own values.
707
+ "h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
708
+ // #69: a numeric column's `meta` overrides the default
709
+ // `text-start` placed right after the base string so
710
+ // tailwind-merge lets it win over that default.
711
+ numericColumnClasses(header.column.columnDef.meta),
712
+ // `sticky`/pinned already establishes a positioning context
713
+ // for the resize handle's `absolute`; an unpinned resizable
714
+ // header needs its own.
715
+ !geometry && canResize && "relative",
716
+ // A pinned HEADER cell is the corner where both freezes meet,
717
+ // so it stacks above the sticky header row (z-20) which is
718
+ // above the pinned body cells (z-10). It needs an OPAQUE
719
+ // fill (scrolled header cells pass underneath it), and that
720
+ // fill has to composite to exactly what its unpinned
721
+ // neighbours show — same problem, same two-layer answer as
722
+ // `pinnedCellFillClass`:
723
+ // sticky branch → the row is already opaque `surface-muted`, so match it.
724
+ // plain branch → the row is `surface-muted/60` over the
725
+ // container's `card`, so paint `card` and
726
+ // re-apply the /60 wash on `::before`.
727
+ // Painting the plain branch's corner solid `surface-muted`
728
+ // read 4-5/255 darker than the header beside it in every
729
+ // theme (measured: 242 vs 247 light, 43 vs 40
730
+ // dark) — the same "floating pill"
731
+ // artefact #333 was filed about, moved into the header.
732
+ geometry && "sticky z-30",
733
+ 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-['']"),
734
+ // Separate cn() argument on purpose: the seam is the sole
735
+ // structural cue between the frozen and scrolling blocks, so
736
+ // it must not read as a "boundary + fill in one class string"
737
+ // redundancy (separation:check).
738
+ geometry?.edgeClass,
739
+ columnDividers && !geometry && COLUMN_DIVIDER_CLASS
740
+ ),
741
+ children: [
742
+ header.isPlaceholder ? null : canSort ? /* @__PURE__ */ jsxs(
743
+ "button",
744
+ {
745
+ type: "button",
746
+ onClick: header.column.getToggleSortingHandler(),
747
+ "aria-label": `Sort by ${headerLabel}, ${sortStateLabel}`,
748
+ className: "relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring",
749
+ children: [
750
+ flexRender(header.column.columnDef.header, header.getContext()),
751
+ /* @__PURE__ */ jsx(
752
+ SortIcon,
753
+ {
754
+ "aria-hidden": "true",
755
+ className: "size-3 shrink-0 transition-colors duration-fast ease-standard"
756
+ }
757
+ )
758
+ ]
759
+ }
760
+ ) : flexRender(header.column.columnDef.header, header.getContext()),
761
+ canResize && /* @__PURE__ */ jsx(
762
+ "div",
763
+ {
764
+ role: "separator",
765
+ "aria-orientation": "vertical",
766
+ "aria-valuenow": Math.round(header.getSize()),
767
+ "aria-valuemin": header.column.columnDef.minSize,
768
+ "aria-valuemax": resizeMax !== void 0 && resizeMax < Number.MAX_SAFE_INTEGER ? resizeMax : Math.max(header.getSize(), RESIZE_UNBOUNDED_ARIA_MAX),
769
+ "aria-valuetext": t("data.table.resizeColumnValue", {
770
+ count: Math.round(header.getSize()),
771
+ size: formatNumber(Math.round(header.getSize()))
772
+ }),
773
+ "aria-label": t("data.table.resizeColumn", { name: headerLabel }),
774
+ tabIndex: 0,
775
+ "data-slot": "data-table-resize-handle",
776
+ onMouseDown: header.getResizeHandler(),
777
+ onTouchStart: header.getResizeHandler(),
778
+ onKeyDown: (event) => handleResizeKeyDown(event, header.column),
779
+ onDoubleClick: () => handleResizeDoubleClick(header.column),
780
+ className: cn(
781
+ // #51: the hit box is a literal 24px (clamped to half
782
+ // the header cell so it can never overlap a neighbour,
783
+ // even at `minSize=20`) rather than the `w-2` Tailwind
784
+ // spacing-scale utility. `w-2` compiles to
785
+ // `calc(var(--spacing) * 2)`, and `--spacing` is what
786
+ // `data-density="compact"` rescales — so the old 8px
787
+ // hit box shrank further under compact density
788
+ // (~7.1px). A literal px value is density-independent
789
+ // by construction, which is the actual defect the
790
+ // maintainer's review corrected (NOT `--type-factor`,
791
+ // which this handle never used). Do not widen via
792
+ // overhang into the neighbouring cell instead — on the
793
+ // last column that lands inside the `overflow-auto`
794
+ // box (#330 false positive) and a pinned neighbour
795
+ // paints over/hit-tests away the extra area.
796
+ "absolute inset-y-0 end-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
797
+ // #51: the focus ring moves to the `after:` pseudo-
798
+ // element (the drawn seam) rather than the box itself
799
+ // — the box is now a 24px hit target, and a 24px focus
800
+ // rectangle would replace the deliberately slim ring
801
+ // already reviewed/approved as the #12 a11y fix
802
+ // (da9b29e). `focus-visible:after:*` targets the
803
+ // pseudo-element the same way `hover:after:w-2` /
804
+ // `focus-visible:after:w-2` below already do.
805
+ "focus-visible:outline-none",
806
+ // a11y fix (#12 review, blocking): this handle is the
807
+ // SOLE boundary between two adjacent header cells once
808
+ // resizing is on — no fill/elevation change separates
809
+ // them otherwise — so per the border/border-strong
810
+ // decision test (styling-and-tokens.md) it needs a
811
+ // rung that clears WCAG 1.4.11's 3:1 on its OWN, in
812
+ // EVERY state, including rest (a control with no
813
+ // affordance until hover is unusable without a
814
+ // pointer). `border-strong` measures only 2.86-2.96:1
815
+ // against this `bg-surface-muted` header — that rung
816
+ // is guaranteed only vs `--card`/`--background`, not a
817
+ // same-tone surface, which is the exact trap the rule
818
+ // warns about. `muted-foreground` is guaranteed AA
819
+ // text contrast against `--surface-muted`
820
+ // (TEXT_SURFACES), so it clears the 3:1 non-text
821
+ // minimum with wide margin (measured ~5.3-6.4:1 in
822
+ // both themes, unaffected by density) and is already
823
+ // the header's own label color. A slim persistent
824
+ // `after:` seam (not just a hover reveal) gives the
825
+ // real resting boundary; hover/focus widen the drawn
826
+ // seam to 8px (`after:w-2`) using the same compliant
827
+ // color — a separate width from the 24px pointer hit
828
+ // box below (#51), which the seam does not fill.
829
+ // Dragging keeps the pre-existing full-fill
830
+ // `bg-primary` treatment — that is a drag AFFORDANCE,
831
+ // not a focus indicator, and it is redundant with the
832
+ // pointer capture, so it is out of scope here. The
833
+ // keyboard focus indicator on both branches is the
834
+ // shared compound one (#67), applied to the drawn seam
835
+ // via `focus-visible:after:focus-ring-static`: the
836
+ // element itself is a 24px transparent hit box, so
837
+ // ringing IT would ring nothing a user can see.
838
+ 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"
839
+ )
840
+ }
841
+ )
842
+ ]
843
+ },
844
+ header.id
845
+ );
846
+ })
847
+ ] }, headerGroup.id))
422
848
  }
423
849
  );
424
850
  }
425
851
  function rowSeparationClass(rowIndex) {
426
852
  if (!zebra) return "border-b border-border-strong last:border-b-0";
427
- return rowIndex % 2 === 1 ? "bg-foreground/5" : "";
853
+ return cn(
854
+ "border-b-(length:--table-row-rule-width) border-border-strong last:border-b-0",
855
+ // Separate cn() argument: the stripe and the (theme-gated) rule are
856
+ // alternative cues, never both at once — see the jsdoc above.
857
+ rowIndex % 2 === 1 && "bg-table-stripe"
858
+ );
428
859
  }
429
860
  function pinnedCellFillClass(rowIndex) {
430
861
  return cn(
431
862
  "bg-card",
432
863
  "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
433
- zebra && rowIndex % 2 === 1 && "before:bg-foreground/5",
434
- "group-hover/row:before:bg-foreground/10",
864
+ zebra && rowIndex % 2 === 1 && "before:bg-table-stripe",
865
+ "group-hover/row:before:bg-table-row-hover",
435
866
  "group-data-[state=selected]/row:before:bg-accent"
436
867
  );
437
868
  }
438
869
  function rowActionName(row) {
439
870
  const explicit = rowActionLabel?.(row);
440
871
  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);
872
+ const name = firstDataCellValue(row);
873
+ if (name !== void 0) return name;
444
874
  return t("data.table.rowAction");
445
875
  }
446
- function renderRow(row, rowIndex, extras) {
876
+ function renderRow(row, rowIndex, extras, dragHandle) {
447
877
  const clickable = Boolean(onRowClick);
448
878
  function handleRowClick(event) {
449
879
  if (isInteractiveEventTarget(event.target)) return;
450
880
  if (isActiveTextSelection()) return;
451
881
  onRowClick?.(row, event);
452
882
  }
453
- return /* @__PURE__ */ jsx(
883
+ return /* @__PURE__ */ jsxs(
454
884
  "tr",
455
885
  {
456
886
  "data-state": row.getIsSelected() ? "selected" : void 0,
@@ -461,7 +891,16 @@ function DataTableInner({
461
891
  // (only movement is neutralized); the gated duration-fast/ease-standard
462
892
  // pair already collapses toward ~0ms via --motion-factor when the user
463
893
  // or OS asks for reduced motion, matching the header sort button.
464
- "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
894
+ "transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-accent",
895
+ // #13: the dragged row's live `transform` (set inline via `extras.style`,
896
+ // see `SortableDataRow`) is what actually MOVES it — this class only
897
+ // makes that movement glide instead of snapping, through the gated
898
+ // duration/ease utilities (never a raw ms/ease value —
899
+ // quality-gates.md "Motion-tokened") with a reduced-motion
900
+ // neutralizer. Raising the dragged row's stacking + opacity is a
901
+ // colour/composite-only cue, so it isn't gated by the same rule.
902
+ dragHandle && "relative transition-transform duration-base ease-standard motion-reduce:transition-none",
903
+ dragHandle?.isDragging && "z-20 opacity-90 shadow-md",
465
904
  // Named group (#333) so a PINNED cell can re-apply the row's hover /
466
905
  // selected wash on top of its own opaque fill — only CSS knows the
467
906
  // pointer is over a sibling cell. Purely a selector hook: `group/row`
@@ -474,58 +913,151 @@ function DataTableInner({
474
913
  // `has-[[data-slot=…]:focus-visible]` pattern as InputGroup) so the
475
914
  // ring paints on the ROW the user is about to activate, even though
476
915
  // 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",
916
+ clickable && "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:focus-ring-static-inset",
478
917
  rowClassName?.(row)
479
918
  ),
480
919
  ...extras,
481
- children: row.getVisibleCells().map((cell, cellIndex) => {
482
- const geometry = pinnedCellGeometry(cell.column);
483
- return /* @__PURE__ */ jsxs(
484
- "td",
920
+ children: [
921
+ dragHandle?.activator && /* @__PURE__ */ jsx("td", { className: "w-10 px-3 py-2 align-middle", children: /* @__PURE__ */ jsx(
922
+ "button",
485
923
  {
486
- "data-pinned": geometry?.pinned ?? void 0,
487
- style: geometry?.style,
924
+ type: "button",
925
+ ref: dragHandle.activator.setActivatorNodeRef,
926
+ "data-slot": "data-table-row-drag-handle",
927
+ "aria-label": t("data.table.reorderHandle", { name: rowActionName(row) }),
488
928
  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
929
+ "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",
930
+ dragHandle.isDragging && "text-foreground"
496
931
  ),
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
- }
932
+ ...dragHandle.activator.attributes,
933
+ ...dragHandle.activator.listeners,
934
+ children: /* @__PURE__ */ jsx(GripVertical, { "aria-hidden": "true", className: "size-4" })
935
+ }
936
+ ) }),
937
+ row.getVisibleCells().map((cell, cellIndex) => {
938
+ const geometry = pinnedCellGeometry(cell.column);
939
+ const resizeStyle = enableColumnResizing ? resizeWidthStyle(cell.column.getSize()) : void 0;
940
+ return /* @__PURE__ */ jsxs(
941
+ "td",
942
+ {
943
+ "data-pinned": geometry?.pinned ?? void 0,
944
+ style: geometry?.style ?? resizeStyle,
945
+ className: cn(
946
+ "px-3 py-2 align-middle",
947
+ // #69: same numeric-column seam as the header — see
948
+ // `numericColumnClasses`.
949
+ numericColumnClasses(cell.column.columnDef.meta),
950
+ // z-10: above the normal (unpositioned) cells it scrolls over,
951
+ // below the sticky header row (z-20) and the pinned corner (z-30).
952
+ geometry && "sticky z-10",
953
+ geometry && pinnedCellFillClass(rowIndex),
954
+ // Separate cn() argument — see pinnedCellGeometry's edgeClass.
955
+ geometry?.edgeClass,
956
+ columnDividers && !geometry && COLUMN_DIVIDER_CLASS
507
957
  ),
508
- flexRender(cell.column.columnDef.cell, cell.getContext())
509
- ]
510
- },
511
- cell.id
512
- );
513
- })
958
+ children: [
959
+ clickable && cellIndex === 0 && /* @__PURE__ */ jsx(
960
+ "button",
961
+ {
962
+ type: "button",
963
+ "data-slot": "data-table-row-action",
964
+ className: "sr-only focus-visible:outline-none",
965
+ onClick: (event) => onRowClick?.(row, event),
966
+ children: rowActionName(row)
967
+ }
968
+ ),
969
+ flexRender(cell.column.columnDef.cell, cell.getContext())
970
+ ]
971
+ },
972
+ cell.id
973
+ );
974
+ })
975
+ ]
514
976
  },
515
977
  row.id
516
978
  );
517
979
  }
518
980
  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}`));
981
+ const visibleColumns = table.getVisibleLeafColumns();
982
+ return Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsxs("tr", { "aria-hidden": "true", className: rowSeparationClass(i), children: [
983
+ hasGripColumn && /* @__PURE__ */ jsx("td", { className: "w-10 px-3 py-2 align-middle", children: /* @__PURE__ */ jsx(Skeleton, { className: "size-4" }) }),
984
+ visibleColumns.map((column) => /* @__PURE__ */ jsx(
985
+ "td",
986
+ {
987
+ className: cn(
988
+ "px-3 py-2 align-middle",
989
+ numericColumnClasses(column.columnDef.meta),
990
+ columnDividers && COLUMN_DIVIDER_CLASS
991
+ ),
992
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" })
993
+ },
994
+ column.id
995
+ ))
996
+ ] }, `skeleton-${i}`));
520
997
  }
521
998
  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 }) });
999
+ return /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx(
1000
+ "td",
1001
+ {
1002
+ colSpan: colCount + (hasGripColumn ? 1 : 0),
1003
+ className: "h-24 px-3 text-center text-muted-foreground",
1004
+ children: emptyMessage
1005
+ }
1006
+ ) });
523
1007
  }
524
1008
  function renderTbodyNormal() {
525
1009
  if (showSkeletons) {
526
1010
  return /* @__PURE__ */ jsx("tbody", { children: renderSkeletonBody(skeletonRowCount) });
527
1011
  }
528
- return /* @__PURE__ */ jsx("tbody", { children: showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i)) });
1012
+ if (showEmpty) {
1013
+ return /* @__PURE__ */ jsx("tbody", { children: renderEmptyBody() });
1014
+ }
1015
+ if (!rowReorderActive) {
1016
+ return /* @__PURE__ */ jsx("tbody", { children: rows.map((row, i) => renderRow(row, i)) });
1017
+ }
1018
+ return /* @__PURE__ */ jsx(
1019
+ SortableContext,
1020
+ {
1021
+ items: rows.map((r) => getReorderRowId(r)),
1022
+ strategy: verticalListSortingStrategy,
1023
+ children: /* @__PURE__ */ jsx("tbody", { children: rows.map((row, i) => /* @__PURE__ */ jsx(
1024
+ SortableDataRow,
1025
+ {
1026
+ id: getReorderRowId(row),
1027
+ attributesOverride: {
1028
+ // #98: dnd-kit's own `roleDescription: 'sortable'` default is
1029
+ // hardcoded English; override it with the localized value in
1030
+ // BOTH handle modes — `role` stays row-mode-only (see the
1031
+ // `attributesOverride` prop doc above).
1032
+ roleDescription: t("data.table.reorderRoleDescription"),
1033
+ ...rowReorderHandle === "row" ? { role: "row" } : null
1034
+ },
1035
+ children: ({ setNodeRef, setActivatorNodeRef, attributes, listeners, isDragging, style }) => renderRow(
1036
+ row,
1037
+ i,
1038
+ {
1039
+ ref: setNodeRef,
1040
+ style,
1041
+ // `aria-pressed` is a `DraggableAttributes` field meant for a
1042
+ // real `<button>` activator; spread onto a `<tr role="row">`
1043
+ // (row-handle mode) it fails axe's `aria-allowed-attr` (that
1044
+ // ARIA state is not permitted on the `row` role), so strip it
1045
+ // here rather than exempt it downstream.
1046
+ ...rowReorderHandle === "row" ? (() => {
1047
+ const { "aria-pressed": _ariaPressed, ...rowAttributes } = attributes;
1048
+ return { ...rowAttributes, ...listeners };
1049
+ })() : {}
1050
+ },
1051
+ {
1052
+ isDragging,
1053
+ activator: rowReorderHandle === "cell" ? { setActivatorNodeRef, attributes, listeners } : void 0
1054
+ }
1055
+ )
1056
+ },
1057
+ getReorderRowId(row)
1058
+ )) })
1059
+ }
1060
+ );
529
1061
  }
530
1062
  function renderTbodyVirtualized() {
531
1063
  if (showSkeletons) {
@@ -592,9 +1124,10 @@ function DataTableInner({
592
1124
  {
593
1125
  ref: scrollRef,
594
1126
  tabIndex: 0,
1127
+ role: "group",
595
1128
  "aria-label": t("data.table.scrollRegion"),
596
1129
  "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",
1130
+ className: "relative overflow-auto rounded-lg border bg-card focus-ring",
598
1131
  style: { maxHeight: maxBodyHeight, ...pinnedScrollPadding },
599
1132
  children: [
600
1133
  loading && rows.length > 0 && /* @__PURE__ */ jsxs(
@@ -605,7 +1138,7 @@ function DataTableInner({
605
1138
  className: "absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80",
606
1139
  children: [
607
1140
  /* @__PURE__ */ jsx(Spinner, { "aria-hidden": "true", className: "text-foreground" }),
608
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Loading table data\u2026" })
1141
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: t("data.table.loading") })
609
1142
  ]
610
1143
  }
611
1144
  ),
@@ -627,7 +1160,7 @@ function DataTableInner({
627
1160
  )
628
1161
  ] });
629
1162
  }
630
- return /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
1163
+ const nonVirtualizedContent = /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
631
1164
  toolbar ? toolbar(table) : null,
632
1165
  /* @__PURE__ */ jsxs(
633
1166
  "div",
@@ -643,7 +1176,7 @@ function DataTableInner({
643
1176
  className: "absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80",
644
1177
  children: [
645
1178
  /* @__PURE__ */ jsx(Spinner, { "aria-hidden": "true", className: "text-foreground" }),
646
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Loading table data\u2026" })
1179
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: t("data.table.loading") })
647
1180
  ]
648
1181
  }
649
1182
  ),
@@ -653,9 +1186,10 @@ function DataTableInner({
653
1186
  ref: plainScrollRef,
654
1187
  "data-slot": "data-table-scroll-region",
655
1188
  tabIndex: scrollOverflows ? 0 : void 0,
1189
+ role: scrollOverflows ? "group" : void 0,
656
1190
  "aria-label": scrollOverflows ? t("data.table.scrollRegion") : void 0,
657
1191
  onScroll: updateScrollAffordance,
658
- className: "overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1192
+ className: "overflow-auto rounded-lg focus-ring-inset",
659
1193
  style: hasLeftPinned || hasRightPinned ? pinnedScrollPadding : void 0,
660
1194
  children: /* @__PURE__ */ jsxs("table", { "aria-busy": loading || void 0, className: "w-full caption-bottom text-body", children: [
661
1195
  captionElement,
@@ -685,28 +1219,75 @@ function DataTableInner({
685
1219
  ),
686
1220
  renderPagination()
687
1221
  ] });
1222
+ if (!rowReorderActive) return nonVirtualizedContent;
1223
+ return /* @__PURE__ */ jsxs(
1224
+ DndContext,
1225
+ {
1226
+ sensors: reorderSensors,
1227
+ collisionDetection: closestCenter,
1228
+ onDragStart: handleRowDragStart,
1229
+ onDragOver: handleRowDragOver,
1230
+ onDragEnd: handleRowDragEnd,
1231
+ onDragCancel: handleRowDragCancel,
1232
+ accessibility: {
1233
+ announcements: silentDragAnnouncements,
1234
+ // #98: dnd-kit's own hidden keyboard-instructions node is hardcoded
1235
+ // English (`defaultScreenReaderInstructions`) unless overridden here.
1236
+ screenReaderInstructions: { draggable: t("data.table.reorderInstructions") }
1237
+ },
1238
+ children: [
1239
+ nonVirtualizedContent,
1240
+ /* @__PURE__ */ jsx(
1241
+ "div",
1242
+ {
1243
+ role: "status",
1244
+ "aria-live": "polite",
1245
+ "aria-atomic": "true",
1246
+ "data-slot": "data-table-reorder-live-region",
1247
+ className: "sr-only",
1248
+ children: reorderLiveMessage
1249
+ }
1250
+ )
1251
+ ]
1252
+ }
1253
+ );
688
1254
  }
689
1255
  var DataTableWithRef = forwardRef(DataTableInner);
690
1256
 
691
1257
  // src/search-input/search-input.tsx
692
- import { useId } from "react";
693
- import { Input } from "@elabs-ai/components-ui";
1258
+ import { forwardRef as forwardRef2, useId, useRef as useRef2 } from "react";
1259
+ import { Input, useLocale as useLocale2 } from "@elabs-ai/components-ui";
694
1260
  import { cn as cn2 } from "@elabs-ai/components-ui/lib/cn";
695
1261
  import { SearchIcon } from "@elabs-ai/components-icons";
696
1262
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
697
- function SearchInput({
1263
+ var SearchInput = forwardRef2(function SearchInput2({
698
1264
  value,
699
1265
  onValueChange,
700
- label = "Search",
701
- placeholder = "Search\u2026",
1266
+ label,
1267
+ placeholder,
702
1268
  className,
703
1269
  containerClassName,
704
1270
  disabled,
1271
+ id: idProp,
705
1272
  ...props
706
- }) {
707
- const id = useId();
1273
+ }, forwardedRef) {
1274
+ const { t } = useLocale2();
1275
+ const resolvedLabel = label ?? t("data.searchInput.label");
1276
+ const resolvedPlaceholder = placeholder ?? t("data.searchInput.placeholder");
1277
+ const generatedId = useId();
1278
+ const id = idProp ?? generatedId;
1279
+ const inputRef = useRef2(null);
1280
+ const setRefs = (node) => {
1281
+ inputRef.current = node;
1282
+ if (typeof forwardedRef === "function") forwardedRef(node);
1283
+ else if (forwardedRef) forwardedRef.current = node;
1284
+ };
1285
+ const handleClear = () => {
1286
+ onValueChange("");
1287
+ inputRef.current?.focus();
1288
+ };
708
1289
  return /* @__PURE__ */ jsxs2("div", { className: cn2("relative w-full max-w-xs", containerClassName), children: [
709
- /* @__PURE__ */ jsx2("label", { htmlFor: id, className: "sr-only", children: label }),
1290
+ /* @__PURE__ */ jsx2("label", { htmlFor: id, className: "sr-only", children: resolvedLabel }),
710
1291
  /* @__PURE__ */ jsx2(
711
1292
  SearchIcon,
712
1293
  {
@@ -717,10 +1298,11 @@ function SearchInput({
717
1298
  /* @__PURE__ */ jsx2(
718
1299
  Input,
719
1300
  {
1301
+ ref: setRefs,
720
1302
  id,
721
1303
  value,
722
1304
  onChange: (e) => onValueChange(e.target.value),
723
- placeholder,
1305
+ placeholder: resolvedPlaceholder,
724
1306
  disabled,
725
1307
  className: cn2("ps-8", value && "pe-8", className),
726
1308
  ...props
@@ -730,9 +1312,9 @@ function SearchInput({
730
1312
  "button",
731
1313
  {
732
1314
  type: "button",
733
- onClick: () => onValueChange(""),
734
- "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",
1315
+ onClick: handleClear,
1316
+ "aria-label": t("data.searchInput.clear"),
1317
+ 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
1318
  children: /* @__PURE__ */ jsx2(
737
1319
  "svg",
738
1320
  {
@@ -751,7 +1333,7 @@ function SearchInput({
751
1333
  }
752
1334
  ) : null
753
1335
  ] });
754
- }
1336
+ });
755
1337
 
756
1338
  // src/filter-bar/filter-bar.tsx
757
1339
  import "react";
@@ -764,8 +1346,31 @@ function FilterBar({ children, actions, className }) {
764
1346
  ] });
765
1347
  }
766
1348
 
1349
+ // src/filter-bar/filter-chip.tsx
1350
+ import { forwardRef as forwardRef3 } from "react";
1351
+ import {
1352
+ FilterChip as BaseFilterChip,
1353
+ useLocale as useLocale3
1354
+ } from "@elabs-ai/components-ui";
1355
+ import { jsx as jsx4 } from "react/jsx-runtime";
1356
+ var FilterChip = forwardRef3(function FilterChip2({ label, count, countLabel, ...props }, ref) {
1357
+ const { formatNumber } = useLocale3();
1358
+ const countText = count === void 0 ? void 0 : countLabel ? `${countLabel} ${formatNumber(count)}` : formatNumber(count);
1359
+ const { trailing: _ignoredTrailing, ...restProps } = props;
1360
+ return /* @__PURE__ */ jsx4(
1361
+ BaseFilterChip,
1362
+ {
1363
+ ref,
1364
+ "data-slot": "filter-chip",
1365
+ label,
1366
+ ...restProps,
1367
+ trailing: countText
1368
+ }
1369
+ );
1370
+ });
1371
+
767
1372
  // src/facet-filter/facet-filter.tsx
768
- import { forwardRef as forwardRef2 } from "react";
1373
+ import { forwardRef as forwardRef4 } from "react";
769
1374
  import {
770
1375
  Badge,
771
1376
  Button as Button2,
@@ -774,11 +1379,13 @@ import {
774
1379
  DropdownMenuItem,
775
1380
  DropdownMenuLabel,
776
1381
  DropdownMenuSeparator,
777
- DropdownMenuTrigger
1382
+ DropdownMenuTrigger,
1383
+ useLocale as useLocale4
778
1384
  } from "@elabs-ai/components-ui";
779
1385
  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) {
1386
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1387
+ var FacetFilter = forwardRef4(function FacetFilter2({ title, options, selected, onSelectedChange, className, ...props }, ref) {
1388
+ const { t } = useLocale4();
782
1389
  const selectedSet = new Set(selected);
783
1390
  const toggle = (value) => {
784
1391
  const next = new Set(selectedSet);
@@ -787,9 +1394,9 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
787
1394
  onSelectedChange([...next]);
788
1395
  };
789
1396
  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: [
1397
+ /* @__PURE__ */ jsx5(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(Button2, { ref, variant: "outline", className: cn4("border-dashed", className), ...props, children: [
791
1398
  title,
792
- selected.length > 0 ? /* @__PURE__ */ jsx4(
1399
+ selected.length > 0 ? /* @__PURE__ */ jsx5(
793
1400
  Badge,
794
1401
  {
795
1402
  variant: "secondary",
@@ -799,7 +1406,7 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
799
1406
  ) : null
800
1407
  ] }) }),
801
1408
  /* @__PURE__ */ jsxs4(DropdownMenuContent, { className: "min-w-[12rem]", children: [
802
- /* @__PURE__ */ jsx4(DropdownMenuLabel, { children: title }),
1409
+ /* @__PURE__ */ jsx5(DropdownMenuLabel, { children: title }),
803
1410
  options.map((opt) => {
804
1411
  const checked = selectedSet.has(opt.value);
805
1412
  return /* @__PURE__ */ jsxs4(
@@ -810,7 +1417,7 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
810
1417
  toggle(opt.value);
811
1418
  },
812
1419
  children: [
813
- /* @__PURE__ */ jsx4(
1420
+ /* @__PURE__ */ jsx5(
814
1421
  "span",
815
1422
  {
816
1423
  "aria-hidden": "true",
@@ -825,8 +1432,8 @@ var FacetFilter = forwardRef2(function FacetFilter2({ title, options, selected,
825
1432
  );
826
1433
  }),
827
1434
  selected.length > 0 ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
828
- /* @__PURE__ */ jsx4(DropdownMenuSeparator, {}),
829
- /* @__PURE__ */ jsx4(DropdownMenuItem, { onSelect: () => onSelectedChange([]), children: "Clear filters" })
1435
+ /* @__PURE__ */ jsx5(DropdownMenuSeparator, {}),
1436
+ /* @__PURE__ */ jsx5(DropdownMenuItem, { onSelect: () => onSelectedChange([]), children: t("data.facetFilter.clearFilters") })
830
1437
  ] }) : null
831
1438
  ] })
832
1439
  ] });
@@ -835,7 +1442,7 @@ FacetFilter.displayName = "FacetFilter";
835
1442
 
836
1443
  // src/column-picker/column-picker.tsx
837
1444
  import "@tanstack/react-table";
838
- import { forwardRef as forwardRef3 } from "react";
1445
+ import { forwardRef as forwardRef5 } from "react";
839
1446
  import {
840
1447
  Button as Button3,
841
1448
  DropdownMenu as DropdownMenu2,
@@ -843,17 +1450,20 @@ import {
843
1450
  DropdownMenuItem as DropdownMenuItem2,
844
1451
  DropdownMenuLabel as DropdownMenuLabel2,
845
1452
  DropdownMenuSeparator as DropdownMenuSeparator2,
846
- DropdownMenuTrigger as DropdownMenuTrigger2
1453
+ DropdownMenuTrigger as DropdownMenuTrigger2,
1454
+ useLocale as useLocale5
847
1455
  } from "@elabs-ai/components-ui";
848
1456
  import { cn as cn5 } from "@elabs-ai/components-ui/lib/cn";
849
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
850
- function ColumnPickerInner({ table, label = "Columns", className, ...props }, ref) {
1457
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1458
+ function ColumnPickerInner({ table, label, className, ...props }, ref) {
1459
+ const { t } = useLocale5();
1460
+ const resolvedLabel = label ?? t("data.columnPicker.label");
851
1461
  const columns = table.getAllColumns().filter((c) => c.getCanHide());
852
1462
  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 }) }),
1463
+ /* @__PURE__ */ jsx6(DropdownMenuTrigger2, { asChild: true, children: /* @__PURE__ */ jsx6(Button3, { ref, variant: "outline", size: "sm", className: cn5(className), ...props, children: resolvedLabel }) }),
854
1464
  /* @__PURE__ */ jsxs5(DropdownMenuContent2, { align: "end", className: "min-w-[12rem]", children: [
855
- /* @__PURE__ */ jsx5(DropdownMenuLabel2, { children: "Toggle columns" }),
856
- /* @__PURE__ */ jsx5(DropdownMenuSeparator2, {}),
1465
+ /* @__PURE__ */ jsx6(DropdownMenuLabel2, { children: t("data.columnPicker.toggleColumns") }),
1466
+ /* @__PURE__ */ jsx6(DropdownMenuSeparator2, {}),
857
1467
  columns.map((column) => /* @__PURE__ */ jsxs5(
858
1468
  DropdownMenuItem2,
859
1469
  {
@@ -862,7 +1472,7 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
862
1472
  column.toggleVisibility(!column.getIsVisible());
863
1473
  },
864
1474
  children: [
865
- /* @__PURE__ */ jsx5(
1475
+ /* @__PURE__ */ jsx6(
866
1476
  "span",
867
1477
  {
868
1478
  "aria-hidden": "true",
@@ -870,7 +1480,7 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
870
1480
  children: column.getIsVisible() ? "\u2713" : ""
871
1481
  }
872
1482
  ),
873
- /* @__PURE__ */ jsx5("span", { className: "capitalize", children: column.id })
1483
+ /* @__PURE__ */ jsx6("span", { className: "capitalize", children: column.id })
874
1484
  ]
875
1485
  },
876
1486
  column.id
@@ -879,26 +1489,12 @@ function ColumnPickerInner({ table, label = "Columns", className, ...props }, re
879
1489
  ] });
880
1490
  }
881
1491
  ColumnPickerInner.displayName = "ColumnPicker";
882
- var ColumnPicker = forwardRef3(ColumnPickerInner);
1492
+ var ColumnPicker = forwardRef5(ColumnPickerInner);
883
1493
 
884
1494
  // src/to-csv.ts
885
- import { downloadBlob } from "@elabs-ai/components-ui";
886
- var INJECTION_PREFIXES = ["=", "+", "-", "@"];
887
- function stringifyValue(value) {
888
- if (value === null || value === void 0) return "";
889
- if (value instanceof Date) return value.toISOString();
890
- if (typeof value === "object") return JSON.stringify(value);
891
- return String(value);
892
- }
893
- function quoteField(field, delimiter) {
894
- if (INJECTION_PREFIXES.some((p) => field.startsWith(p))) {
895
- field = "'" + field;
896
- }
897
- if (field.includes(delimiter) || field.includes('"') || field.includes("\n") || field.includes("\r")) {
898
- return '"' + field.replaceAll('"', '""') + '"';
899
- }
900
- return field;
901
- }
1495
+ import { csvQuoteField, csvStringifyValue, downloadBlob } from "@elabs-ai/components-ui";
1496
+ var stringifyValue = csvStringifyValue;
1497
+ var quoteField = csvQuoteField;
902
1498
  function toCsv(rows, opts) {
903
1499
  const delimiter = opts?.delimiter ?? ",";
904
1500
  const includeHeader = opts?.header !== false;
@@ -926,7 +1522,9 @@ export {
926
1522
  DataTableWithRef as DataTable,
927
1523
  FacetFilter,
928
1524
  FilterBar,
1525
+ FilterChip,
929
1526
  SearchInput,
1527
+ createSelectionColumn,
930
1528
  downloadCsv,
931
1529
  toCsv
932
1530
  };