@elabs-ai/components-data 4.0.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 ADDED
@@ -0,0 +1,933 @@
1
+ "use client";
2
+
3
+ // src/data-table/data-table.tsx
4
+ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
5
+ import {
6
+ flexRender,
7
+ getCoreRowModel,
8
+ getFilteredRowModel,
9
+ getPaginationRowModel,
10
+ getSortedRowModel,
11
+ useReactTable
12
+ } from "@tanstack/react-table";
13
+ 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";
16
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
17
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
18
+ 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
+ function isInteractiveEventTarget(target) {
20
+ return target instanceof Element && target.closest(ROW_CLICK_GUARD_SELECTOR) !== null;
21
+ }
22
+ function isActiveTextSelection() {
23
+ if (typeof window === "undefined" || typeof window.getSelection !== "function") return false;
24
+ return window.getSelection()?.type === "Range";
25
+ }
26
+ var PINNED_SEAM_CLASS = "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
27
+ function unsizedColumnIds(defs) {
28
+ const out = /* @__PURE__ */ new Set();
29
+ const walk = (list) => {
30
+ for (const def of list) {
31
+ const group = def;
32
+ if (group.columns) {
33
+ walk(group.columns);
34
+ continue;
35
+ }
36
+ if (def.size !== void 0) continue;
37
+ const accessorKey = def.accessorKey;
38
+ const id = def.id ?? (accessorKey !== void 0 ? String(accessorKey).replace(/\./gu, "_") : typeof def.header === "string" ? def.header : void 0);
39
+ if (id) out.add(id);
40
+ }
41
+ };
42
+ walk(defs);
43
+ return out;
44
+ }
45
+ function DataTableInner({
46
+ columns,
47
+ data,
48
+ toolbar,
49
+ enablePagination = false,
50
+ pageSize = 10,
51
+ hidePaginationWhenSingle = true,
52
+ // Global filter
53
+ globalFilter: globalFilterProp,
54
+ onGlobalFilterChange,
55
+ // Controlled slices
56
+ sorting: sortingProp,
57
+ onSortingChange: onSortingChangeProp,
58
+ columnVisibility: columnVisibilityProp,
59
+ onColumnVisibilityChange: onColumnVisibilityChangeProp,
60
+ columnFilters: columnFiltersProp,
61
+ onColumnFiltersChange: onColumnFiltersChangeProp,
62
+ pagination: paginationProp,
63
+ onPaginationChange: onPaginationChangeProp,
64
+ columnPinning: columnPinningProp,
65
+ onColumnPinningChange: onColumnPinningChangeProp,
66
+ // Saved views rehydration
67
+ initialView,
68
+ // Server-side model
69
+ manualSorting = false,
70
+ manualFiltering = false,
71
+ manualPagination = false,
72
+ rowCount,
73
+ pageCount,
74
+ onServerChange,
75
+ // Loading
76
+ loading = false,
77
+ loadingRows,
78
+ // Virtualization
79
+ enableRowVirtualization = false,
80
+ estimateRowHeight = 40,
81
+ overscan = 8,
82
+ maxBodyHeight = "32rem",
83
+ zebra = true,
84
+ onRowClick,
85
+ rowActionLabel,
86
+ rowClassName,
87
+ caption,
88
+ emptyMessage = "No results.",
89
+ className,
90
+ ...rest
91
+ }, ref) {
92
+ const { t } = useLocale();
93
+ const isSortingControlled = sortingProp !== void 0;
94
+ const isColumnVisibilityControlled = columnVisibilityProp !== void 0;
95
+ const isColumnFiltersControlled = columnFiltersProp !== void 0;
96
+ const isPaginationControlled = paginationProp !== void 0;
97
+ const isFilterControlled = globalFilterProp !== void 0;
98
+ const isColumnPinningControlled = columnPinningProp !== void 0;
99
+ const [internalSorting, setInternalSorting] = useState(
100
+ () => initialView?.sorting ?? []
101
+ );
102
+ const [internalColumnVisibility, setInternalColumnVisibility] = useState(
103
+ () => initialView?.columnVisibility ?? {}
104
+ );
105
+ const [internalColumnFilters, setInternalColumnFilters] = useState(
106
+ () => initialView?.columnFilters ?? []
107
+ );
108
+ const [internalPagination, setInternalPagination] = useState(
109
+ () => initialView?.pagination ?? {
110
+ pageIndex: 0,
111
+ pageSize
112
+ }
113
+ );
114
+ const [internalGlobalFilter, setInternalGlobalFilter] = useState(
115
+ () => initialView?.globalFilter ?? ""
116
+ );
117
+ const [internalColumnPinning, setInternalColumnPinning] = useState(
118
+ () => initialView?.columnPinning ?? { left: [], right: [] }
119
+ );
120
+ const sorting = isSortingControlled ? sortingProp : internalSorting;
121
+ const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility;
122
+ const columnFilters = isColumnFiltersControlled ? columnFiltersProp : internalColumnFilters;
123
+ const pagination = isPaginationControlled ? paginationProp : internalPagination;
124
+ const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
125
+ const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
126
+ const sortingRef = useRef(sorting);
127
+ sortingRef.current = sorting;
128
+ const columnFiltersRef = useRef(columnFilters);
129
+ columnFiltersRef.current = columnFilters;
130
+ const paginationRef = useRef(pagination);
131
+ paginationRef.current = pagination;
132
+ const globalFilterRef = useRef(globalFilter);
133
+ globalFilterRef.current = globalFilter;
134
+ const columnVisibilityRef = useRef(columnVisibility);
135
+ columnVisibilityRef.current = columnVisibility;
136
+ const columnPinningRef = useRef(columnPinning);
137
+ columnPinningRef.current = columnPinning;
138
+ const warnedMissingRowCountRef = useRef(false);
139
+ useEffect(() => {
140
+ if (process.env.NODE_ENV !== "production" && manualPagination && rowCount === void 0 && pageCount === void 0 && !warnedMissingRowCountRef.current) {
141
+ warnedMissingRowCountRef.current = true;
142
+ console.warn(
143
+ '[DataTable] `manualPagination` is true but neither `rowCount` nor `pageCount` was provided \u2014 the pager will appear stuck ("Page 1 of 1", Next disabled). Pass `rowCount` (or `pageCount`) so the pager can compute the total.'
144
+ );
145
+ }
146
+ }, [manualPagination, rowCount, pageCount]);
147
+ function fireServerChange(overrides = {}) {
148
+ if (!onServerChange) return;
149
+ onServerChange({
150
+ pagination: paginationRef.current,
151
+ sorting: sortingRef.current,
152
+ columnFilters: columnFiltersRef.current,
153
+ globalFilter: globalFilterRef.current,
154
+ ...overrides
155
+ });
156
+ }
157
+ function resolveSorting(updater) {
158
+ return typeof updater === "function" ? updater(sortingRef.current) : updater;
159
+ }
160
+ function resolveColumnVisibility(updater) {
161
+ return typeof updater === "function" ? updater(columnVisibilityRef.current) : updater;
162
+ }
163
+ function resolveColumnFilters(updater) {
164
+ return typeof updater === "function" ? updater(columnFiltersRef.current) : updater;
165
+ }
166
+ function resolvePagination(updater) {
167
+ return typeof updater === "function" ? updater(paginationRef.current) : updater;
168
+ }
169
+ function resolveGlobalFilter(updater) {
170
+ return typeof updater === "function" ? updater(globalFilterRef.current) : updater;
171
+ }
172
+ function resolveColumnPinning(updater) {
173
+ return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
174
+ }
175
+ const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
176
+ const filteredRowModel = manualFiltering ? {} : { getFilteredRowModel: getFilteredRowModel() };
177
+ const paginationRowModel = enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
178
+ const table = useReactTable({
179
+ data,
180
+ columns,
181
+ state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
182
+ // Sorting
183
+ onSortingChange: (updater) => {
184
+ const next = resolveSorting(updater);
185
+ if (!isSortingControlled) setInternalSorting(next);
186
+ onSortingChangeProp?.(updater);
187
+ if (manualSorting) {
188
+ sortingRef.current = next;
189
+ fireServerChange({ sorting: next });
190
+ }
191
+ },
192
+ // Column visibility
193
+ onColumnVisibilityChange: (updater) => {
194
+ const next = resolveColumnVisibility(updater);
195
+ if (!isColumnVisibilityControlled) setInternalColumnVisibility(next);
196
+ onColumnVisibilityChangeProp?.(updater);
197
+ },
198
+ // Column filters
199
+ onColumnFiltersChange: (updater) => {
200
+ const next = resolveColumnFilters(updater);
201
+ if (!isColumnFiltersControlled) setInternalColumnFilters(next);
202
+ onColumnFiltersChangeProp?.(updater);
203
+ if (manualFiltering) {
204
+ columnFiltersRef.current = next;
205
+ fireServerChange({ columnFilters: next });
206
+ }
207
+ },
208
+ // Global filter
209
+ onGlobalFilterChange: (updater) => {
210
+ const next = resolveGlobalFilter(updater);
211
+ if (!isFilterControlled) setInternalGlobalFilter(next);
212
+ onGlobalFilterChange?.(next);
213
+ if (manualFiltering) {
214
+ globalFilterRef.current = next;
215
+ fireServerChange({ globalFilter: next });
216
+ }
217
+ },
218
+ // Pagination
219
+ onPaginationChange: (updater) => {
220
+ const next = resolvePagination(updater);
221
+ if (!isPaginationControlled) setInternalPagination(next);
222
+ onPaginationChangeProp?.(updater);
223
+ if (manualPagination) {
224
+ paginationRef.current = next;
225
+ fireServerChange({ pagination: next });
226
+ }
227
+ },
228
+ // Column pinning — a LAYOUT slice, so unlike sorting/filtering/pagination it
229
+ // never fires `onServerChange`: freezing a column changes nothing the server
230
+ // would need to re-query.
231
+ onColumnPinningChange: (updater) => {
232
+ const next = resolveColumnPinning(updater);
233
+ if (!isColumnPinningControlled) setInternalColumnPinning(next);
234
+ onColumnPinningChangeProp?.(updater);
235
+ },
236
+ getCoreRowModel: getCoreRowModel(),
237
+ ...sortedRowModel,
238
+ ...filteredRowModel,
239
+ ...paginationRowModel,
240
+ // Server-side options
241
+ manualSorting,
242
+ manualFiltering,
243
+ manualPagination,
244
+ ...rowCount !== void 0 ? { rowCount } : {},
245
+ ...pageCount !== void 0 ? { pageCount } : {}
246
+ // No `initialState`: every slice is driven explicitly via `state` above
247
+ // (internal slices are seeded from `initialView` at useState init), so a
248
+ // TanStack `initialState` would be dead/misleading.
249
+ });
250
+ const rows = table.getRowModel().rows;
251
+ const colCount = table.getVisibleLeafColumns().length;
252
+ const headerRowCount = table.getHeaderGroups().length;
253
+ const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
254
+ const hasLeftPinned = (columnPinning.left?.length ?? 0) > 0;
255
+ const hasRightPinned = (columnPinning.right?.length ?? 0) > 0;
256
+ const pinnedScrollPadding = {
257
+ ...hasLeftPinned ? { scrollPaddingInlineStart: table.getLeftTotalSize() } : {},
258
+ ...hasRightPinned ? { scrollPaddingInlineEnd: table.getRightTotalSize() } : {}
259
+ };
260
+ const warnedUnsizedPinnedRef = useRef(false);
261
+ const pinnedIds = [...columnPinning.left ?? [], ...columnPinning.right ?? []];
262
+ const unsizedIds = process.env.NODE_ENV === "production" || pinnedIds.length === 0 ? null : unsizedColumnIds(columns);
263
+ const pinnedWithoutSizeKey = unsizedIds ? pinnedIds.filter((id) => unsizedIds.has(id)).join(",") : "";
264
+ useEffect(() => {
265
+ if (process.env.NODE_ENV !== "production" && pinnedWithoutSizeKey !== "" && !warnedUnsizedPinnedRef.current) {
266
+ warnedUnsizedPinnedRef.current = true;
267
+ console.warn(
268
+ `[DataTable] Pinned column(s) without an explicit \`size\` in their \`ColumnDef\`: ${pinnedWithoutSizeKey}. Sticky offsets are computed from the declared sizes, so an auto-width pinned column will render at a width that doesn't match its own offset. Give every pinned column a \`size\`.`
269
+ );
270
+ }
271
+ }, [pinnedWithoutSizeKey]);
272
+ function pinnedCellGeometry(column) {
273
+ const pinned = column.getIsPinned();
274
+ if (pinned === false) return null;
275
+ const size = column.getSize();
276
+ const style = {
277
+ width: size,
278
+ minWidth: size,
279
+ maxWidth: size,
280
+ ...pinned === "left" ? { left: column.getStart("left") } : { right: column.getAfter("right") }
281
+ };
282
+ return {
283
+ pinned,
284
+ style,
285
+ // The seam between the frozen block and the scrolling block is the SOLE
286
+ // structural cue between two regions that share one row fill and one
287
+ // zebra stripe — delete it and a sighted user cannot tell them apart — so
288
+ // it takes the strong rung (ADR 0010 decision test). No shadow: ADR 0020's
289
+ // `--shadow-strength: 0` (`data-decoration="8|9|10"`) would
290
+ // erase a shadow-only cue entirely.
291
+ //
292
+ // It is drawn as a 1px `::after` INSIDE the cell, NOT as `border-e` /
293
+ // `border-s`. A real border cannot work here: Tailwind's Preflight puts
294
+ // the table in the COLLAPSED border model, and a collapsed border is
295
+ // painted by the <table> at the cell's STATIC position — it does not
296
+ // travel with a `position: sticky` cell, and the cell's own opaque fill
297
+ // (which it needs, see `pinnedCellFillClass`) then paints over it. Measured
298
+ // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
299
+ // seam pixel read `143,143,143` (light `--border-strong`) at
300
+ // 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
302
+ // exactly when the freeze was doing something. The `::after` lives in the
303
+ // sticky cell's own stacking context, so it moves with it.
304
+ edgeClass: pinned === "left" ? column.getIsLastColumn("left") ? PINNED_SEAM_CLASS + " after:end-0" : "" : column.getIsFirstColumn("right") ? PINNED_SEAM_CLASS + " after:start-0" : ""
305
+ };
306
+ }
307
+ const scrollRef = useRef(null);
308
+ const virtualizer = useVirtualizer({
309
+ count: enableRowVirtualization ? rows.length : 0,
310
+ getScrollElement: () => enableRowVirtualization ? scrollRef.current : null,
311
+ estimateSize: () => estimateRowHeight,
312
+ overscan,
313
+ enabled: enableRowVirtualization
314
+ });
315
+ const virtualItems = enableRowVirtualization ? virtualizer.getVirtualItems() : [];
316
+ const totalSize = enableRowVirtualization ? virtualizer.getTotalSize() : 0;
317
+ const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
318
+ const paddingBottom = totalSize > 0 ? totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0) : 0;
319
+ const plainScrollRef = useRef(null);
320
+ const [scrollOverflows, setScrollOverflows] = useState(false);
321
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
322
+ const [canScrollRight, setCanScrollRight] = useState(false);
323
+ const updateScrollAffordance = useCallback(() => {
324
+ const el = plainScrollRef.current;
325
+ if (!el) return;
326
+ setScrollOverflows(
327
+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1
328
+ );
329
+ setCanScrollLeft(el.scrollLeft > 0);
330
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
331
+ }, []);
332
+ useEffect(() => {
333
+ const el = plainScrollRef.current;
334
+ if (!el) return;
335
+ updateScrollAffordance();
336
+ if (typeof ResizeObserver === "undefined") return;
337
+ const observer = new ResizeObserver(updateScrollAffordance);
338
+ observer.observe(el);
339
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
340
+ return () => observer.disconnect();
341
+ }, [updateScrollAffordance, colCount, rows.length]);
342
+ const showEmpty = !loading && rows.length === 0;
343
+ const showSkeletons = loading && rows.length === 0;
344
+ const skeletonRowCount = loadingRows ?? pageSize;
345
+ function renderThead(sticky, withRowIndex = false) {
346
+ return /* @__PURE__ */ jsx(
347
+ "thead",
348
+ {
349
+ className: cn(
350
+ // #173: header bottom is the only cue between header and first data row → border-strong
351
+ "border-b border-border-strong",
352
+ // A sticky header scrolls OVER the body, so its fill must be opaque or data
353
+ // rows bleed through the labels; the non-sticky header keeps the /60 wash.
354
+ // z-20 (raised from z-10 for #333) puts the header row above the pinned
355
+ // body cells (z-10) and below the pinned header corner (z-30). No visual
356
+ // delta: nothing else in the table sits between those rungs.
357
+ sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60"
358
+ ),
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))
422
+ }
423
+ );
424
+ }
425
+ function rowSeparationClass(rowIndex) {
426
+ if (!zebra) return "border-b border-border-strong last:border-b-0";
427
+ return rowIndex % 2 === 1 ? "bg-foreground/5" : "";
428
+ }
429
+ function pinnedCellFillClass(rowIndex) {
430
+ return cn(
431
+ "bg-card",
432
+ "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",
435
+ "group-data-[state=selected]/row:before:bg-accent"
436
+ );
437
+ }
438
+ function rowActionName(row) {
439
+ const explicit = rowActionLabel?.(row);
440
+ 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);
444
+ return t("data.table.rowAction");
445
+ }
446
+ function renderRow(row, rowIndex, extras) {
447
+ const clickable = Boolean(onRowClick);
448
+ function handleRowClick(event) {
449
+ if (isInteractiveEventTarget(event.target)) return;
450
+ if (isActiveTextSelection()) return;
451
+ onRowClick?.(row, event);
452
+ }
453
+ return /* @__PURE__ */ jsx(
454
+ "tr",
455
+ {
456
+ "data-state": row.getIsSelected() ? "selected" : void 0,
457
+ onClick: clickable ? handleRowClick : void 0,
458
+ className: cn(
459
+ // Color-only feedback (no transform/movement) → per
460
+ // docs/MOTION_GUIDELINES.md item 3 this stays under OS reduced-motion
461
+ // (only movement is neutralized); the gated duration-fast/ease-standard
462
+ // pair already collapses toward ~0ms via --motion-factor when the user
463
+ // 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",
465
+ // Named group (#333) so a PINNED cell can re-apply the row's hover /
466
+ // selected wash on top of its own opaque fill — only CSS knows the
467
+ // pointer is over a sibling cell. Purely a selector hook: `group/row`
468
+ // emits no style of its own.
469
+ "group/row",
470
+ rowSeparationClass(rowIndex),
471
+ // `<tr>` isn't in the global auto-cursor-pointer role list (button/
472
+ // menuitem/tab/…), so a clickable row needs its own cursor. The focus
473
+ // ring is driven off the hidden button's `:focus-visible` (same
474
+ // `has-[[data-slot=…]:focus-visible]` pattern as InputGroup) so the
475
+ // ring paints on the ROW the user is about to activate, even though
476
+ // 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",
478
+ rowClassName?.(row)
479
+ ),
480
+ ...extras,
481
+ children: row.getVisibleCells().map((cell, cellIndex) => {
482
+ const geometry = pinnedCellGeometry(cell.column);
483
+ return /* @__PURE__ */ jsxs(
484
+ "td",
485
+ {
486
+ "data-pinned": geometry?.pinned ?? void 0,
487
+ style: geometry?.style,
488
+ 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
496
+ ),
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
+ }
507
+ ),
508
+ flexRender(cell.column.columnDef.cell, cell.getContext())
509
+ ]
510
+ },
511
+ cell.id
512
+ );
513
+ })
514
+ },
515
+ row.id
516
+ );
517
+ }
518
+ 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}`));
520
+ }
521
+ 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 }) });
523
+ }
524
+ function renderTbodyNormal() {
525
+ if (showSkeletons) {
526
+ return /* @__PURE__ */ jsx("tbody", { children: renderSkeletonBody(skeletonRowCount) });
527
+ }
528
+ return /* @__PURE__ */ jsx("tbody", { children: showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i)) });
529
+ }
530
+ function renderTbodyVirtualized() {
531
+ if (showSkeletons) {
532
+ const virtualSkeletonCount = loadingRows ?? Math.min(10, pageSize);
533
+ return /* @__PURE__ */ jsx("tbody", { children: renderSkeletonBody(virtualSkeletonCount) });
534
+ }
535
+ return /* @__PURE__ */ jsx("tbody", { children: showEmpty ? renderEmptyBody() : /* @__PURE__ */ jsxs(Fragment, { children: [
536
+ paddingTop > 0 && /* @__PURE__ */ jsx("tr", { "aria-hidden": "true", children: /* @__PURE__ */ jsx("td", { style: { height: paddingTop }, colSpan: colCount }) }),
537
+ virtualItems.map((virtualRow) => {
538
+ const row = rows[virtualRow.index];
539
+ if (!row) return null;
540
+ return renderRow(row, virtualRow.index, {
541
+ ref: virtualizer.measureElement,
542
+ "data-index": virtualRow.index,
543
+ // Absolute 1-based row position; header row(s) occupy 1..headerRowCount.
544
+ "aria-rowindex": headerRowCount + virtualRow.index + 1
545
+ });
546
+ }),
547
+ paddingBottom > 0 && /* @__PURE__ */ jsx("tr", { "aria-hidden": "true", children: /* @__PURE__ */ jsx("td", { style: { height: paddingBottom }, colSpan: colCount }) })
548
+ ] }) });
549
+ }
550
+ function renderPagination() {
551
+ if (enableRowVirtualization) return null;
552
+ if (!enablePagination && !manualPagination) return null;
553
+ const pageCountUnknown = manualPagination && rowCount === void 0 && pageCount === void 0;
554
+ if (hidePaginationWhenSingle && !pageCountUnknown && table.getPageCount() <= 1) return null;
555
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
556
+ /* @__PURE__ */ jsxs("p", { className: "text-body text-muted-foreground", children: [
557
+ "Page ",
558
+ table.getState().pagination.pageIndex + 1,
559
+ " of ",
560
+ table.getPageCount() || 1
561
+ ] }),
562
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
563
+ /* @__PURE__ */ jsx(
564
+ Button,
565
+ {
566
+ variant: "outline",
567
+ size: "sm",
568
+ onClick: () => table.previousPage(),
569
+ disabled: !table.getCanPreviousPage(),
570
+ children: "Previous"
571
+ }
572
+ ),
573
+ /* @__PURE__ */ jsx(
574
+ Button,
575
+ {
576
+ variant: "outline",
577
+ size: "sm",
578
+ onClick: () => table.nextPage(),
579
+ disabled: !table.getCanNextPage(),
580
+ children: "Next"
581
+ }
582
+ )
583
+ ] })
584
+ ] });
585
+ }
586
+ const captionElement = caption != null ? /* @__PURE__ */ jsx("caption", { className: "sr-only", children: caption }) : null;
587
+ if (enableRowVirtualization) {
588
+ return /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
589
+ toolbar ? toolbar(table) : null,
590
+ /* @__PURE__ */ jsxs(
591
+ "div",
592
+ {
593
+ ref: scrollRef,
594
+ tabIndex: 0,
595
+ "aria-label": t("data.table.scrollRegion"),
596
+ "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",
598
+ style: { maxHeight: maxBodyHeight, ...pinnedScrollPadding },
599
+ children: [
600
+ loading && rows.length > 0 && /* @__PURE__ */ jsxs(
601
+ "div",
602
+ {
603
+ role: "status",
604
+ "aria-live": "polite",
605
+ className: "absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80",
606
+ children: [
607
+ /* @__PURE__ */ jsx(Spinner, { "aria-hidden": "true", className: "text-foreground" }),
608
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Loading table data\u2026" })
609
+ ]
610
+ }
611
+ ),
612
+ /* @__PURE__ */ jsxs(
613
+ "table",
614
+ {
615
+ "aria-busy": loading || void 0,
616
+ "aria-rowcount": ariaRowCount,
617
+ className: "w-full caption-bottom text-body",
618
+ children: [
619
+ captionElement,
620
+ renderThead(true, true),
621
+ renderTbodyVirtualized()
622
+ ]
623
+ }
624
+ )
625
+ ]
626
+ }
627
+ )
628
+ ] });
629
+ }
630
+ return /* @__PURE__ */ jsxs("div", { ref, className: cn("space-y-3", className), ...rest, children: [
631
+ toolbar ? toolbar(table) : null,
632
+ /* @__PURE__ */ jsxs(
633
+ "div",
634
+ {
635
+ "aria-busy": loading || void 0,
636
+ className: "relative overflow-hidden rounded-lg border bg-card",
637
+ children: [
638
+ loading && rows.length > 0 && /* @__PURE__ */ jsxs(
639
+ "div",
640
+ {
641
+ role: "status",
642
+ "aria-live": "polite",
643
+ className: "absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80",
644
+ children: [
645
+ /* @__PURE__ */ jsx(Spinner, { "aria-hidden": "true", className: "text-foreground" }),
646
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Loading table data\u2026" })
647
+ ]
648
+ }
649
+ ),
650
+ /* @__PURE__ */ jsx(
651
+ "div",
652
+ {
653
+ ref: plainScrollRef,
654
+ "data-slot": "data-table-scroll-region",
655
+ tabIndex: scrollOverflows ? 0 : void 0,
656
+ "aria-label": scrollOverflows ? t("data.table.scrollRegion") : void 0,
657
+ onScroll: updateScrollAffordance,
658
+ className: "overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
659
+ style: hasLeftPinned || hasRightPinned ? pinnedScrollPadding : void 0,
660
+ children: /* @__PURE__ */ jsxs("table", { "aria-busy": loading || void 0, className: "w-full caption-bottom text-body", children: [
661
+ captionElement,
662
+ renderThead(false),
663
+ renderTbodyNormal()
664
+ ] })
665
+ }
666
+ ),
667
+ canScrollLeft && !hasLeftPinned && /* @__PURE__ */ jsx(
668
+ "div",
669
+ {
670
+ "aria-hidden": "true",
671
+ "data-slot": "data-table-scroll-fade-left",
672
+ className: "pointer-events-none absolute inset-y-0 left-0 z-10 w-8 rounded-lg bg-gradient-to-r from-card to-transparent"
673
+ }
674
+ ),
675
+ canScrollRight && !hasRightPinned && /* @__PURE__ */ jsx(
676
+ "div",
677
+ {
678
+ "aria-hidden": "true",
679
+ "data-slot": "data-table-scroll-fade-right",
680
+ className: "pointer-events-none absolute inset-y-0 right-0 z-10 w-8 rounded-lg bg-gradient-to-l from-card to-transparent"
681
+ }
682
+ )
683
+ ]
684
+ }
685
+ ),
686
+ renderPagination()
687
+ ] });
688
+ }
689
+ var DataTableWithRef = forwardRef(DataTableInner);
690
+
691
+ // src/search-input/search-input.tsx
692
+ import { useId } from "react";
693
+ import { Input } from "@elabs-ai/components-ui";
694
+ import { cn as cn2 } from "@elabs-ai/components-ui/lib/cn";
695
+ import { SearchIcon } from "@elabs-ai/components-icons";
696
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
697
+ function SearchInput({
698
+ value,
699
+ onValueChange,
700
+ label = "Search",
701
+ placeholder = "Search\u2026",
702
+ className,
703
+ containerClassName,
704
+ disabled,
705
+ ...props
706
+ }) {
707
+ const id = useId();
708
+ 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 }),
710
+ /* @__PURE__ */ jsx2(
711
+ SearchIcon,
712
+ {
713
+ size: 16,
714
+ className: "pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
715
+ }
716
+ ),
717
+ /* @__PURE__ */ jsx2(
718
+ Input,
719
+ {
720
+ id,
721
+ value,
722
+ onChange: (e) => onValueChange(e.target.value),
723
+ placeholder,
724
+ disabled,
725
+ className: cn2("ps-8", value && "pe-8", className),
726
+ ...props
727
+ }
728
+ ),
729
+ value && !disabled ? /* @__PURE__ */ jsx2(
730
+ "button",
731
+ {
732
+ 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",
736
+ children: /* @__PURE__ */ jsx2(
737
+ "svg",
738
+ {
739
+ width: "14",
740
+ height: "14",
741
+ viewBox: "0 0 24 24",
742
+ fill: "none",
743
+ stroke: "currentColor",
744
+ strokeWidth: "2",
745
+ strokeLinecap: "round",
746
+ strokeLinejoin: "round",
747
+ "aria-hidden": "true",
748
+ children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" })
749
+ }
750
+ )
751
+ }
752
+ ) : null
753
+ ] });
754
+ }
755
+
756
+ // src/filter-bar/filter-bar.tsx
757
+ import "react";
758
+ import { cn as cn3 } from "@elabs-ai/components-ui/lib/cn";
759
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
760
+ function FilterBar({ children, actions, className }) {
761
+ return /* @__PURE__ */ jsxs3("div", { className: cn3("flex flex-wrap items-center justify-between gap-2", className), children: [
762
+ /* @__PURE__ */ jsx3("div", { className: "flex flex-wrap items-center gap-2", children }),
763
+ actions ? /* @__PURE__ */ jsx3("div", { className: "flex items-center gap-2", children: actions }) : null
764
+ ] });
765
+ }
766
+
767
+ // src/facet-filter/facet-filter.tsx
768
+ import { forwardRef as forwardRef2 } from "react";
769
+ import {
770
+ Badge,
771
+ Button as Button2,
772
+ DropdownMenu,
773
+ DropdownMenuContent,
774
+ DropdownMenuItem,
775
+ DropdownMenuLabel,
776
+ DropdownMenuSeparator,
777
+ DropdownMenuTrigger
778
+ } from "@elabs-ai/components-ui";
779
+ 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) {
782
+ const selectedSet = new Set(selected);
783
+ const toggle = (value) => {
784
+ const next = new Set(selectedSet);
785
+ if (next.has(value)) next.delete(value);
786
+ else next.add(value);
787
+ onSelectedChange([...next]);
788
+ };
789
+ 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: [
791
+ title,
792
+ selected.length > 0 ? /* @__PURE__ */ jsx4(
793
+ Badge,
794
+ {
795
+ variant: "secondary",
796
+ className: "ms-1 rounded px-1.5 animate-in fade-in zoom-in-95 duration-fast ease-entrance",
797
+ children: selected.length
798
+ }
799
+ ) : null
800
+ ] }) }),
801
+ /* @__PURE__ */ jsxs4(DropdownMenuContent, { className: "min-w-[12rem]", children: [
802
+ /* @__PURE__ */ jsx4(DropdownMenuLabel, { children: title }),
803
+ options.map((opt) => {
804
+ const checked = selectedSet.has(opt.value);
805
+ return /* @__PURE__ */ jsxs4(
806
+ DropdownMenuItem,
807
+ {
808
+ onSelect: (e) => {
809
+ e.preventDefault();
810
+ toggle(opt.value);
811
+ },
812
+ children: [
813
+ /* @__PURE__ */ jsx4(
814
+ "span",
815
+ {
816
+ "aria-hidden": "true",
817
+ className: "flex size-4 items-center justify-center rounded border transition-colors duration-fast ease-standard " + (checked ? "border-primary bg-primary text-primary-foreground" : "border-input"),
818
+ children: checked ? "\u2713" : ""
819
+ }
820
+ ),
821
+ opt.label
822
+ ]
823
+ },
824
+ opt.value
825
+ );
826
+ }),
827
+ selected.length > 0 ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
828
+ /* @__PURE__ */ jsx4(DropdownMenuSeparator, {}),
829
+ /* @__PURE__ */ jsx4(DropdownMenuItem, { onSelect: () => onSelectedChange([]), children: "Clear filters" })
830
+ ] }) : null
831
+ ] })
832
+ ] });
833
+ });
834
+ FacetFilter.displayName = "FacetFilter";
835
+
836
+ // src/column-picker/column-picker.tsx
837
+ import "@tanstack/react-table";
838
+ import { forwardRef as forwardRef3 } from "react";
839
+ import {
840
+ Button as Button3,
841
+ DropdownMenu as DropdownMenu2,
842
+ DropdownMenuContent as DropdownMenuContent2,
843
+ DropdownMenuItem as DropdownMenuItem2,
844
+ DropdownMenuLabel as DropdownMenuLabel2,
845
+ DropdownMenuSeparator as DropdownMenuSeparator2,
846
+ DropdownMenuTrigger as DropdownMenuTrigger2
847
+ } from "@elabs-ai/components-ui";
848
+ 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) {
851
+ const columns = table.getAllColumns().filter((c) => c.getCanHide());
852
+ 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 }) }),
854
+ /* @__PURE__ */ jsxs5(DropdownMenuContent2, { align: "end", className: "min-w-[12rem]", children: [
855
+ /* @__PURE__ */ jsx5(DropdownMenuLabel2, { children: "Toggle columns" }),
856
+ /* @__PURE__ */ jsx5(DropdownMenuSeparator2, {}),
857
+ columns.map((column) => /* @__PURE__ */ jsxs5(
858
+ DropdownMenuItem2,
859
+ {
860
+ onSelect: (e) => {
861
+ e.preventDefault();
862
+ column.toggleVisibility(!column.getIsVisible());
863
+ },
864
+ children: [
865
+ /* @__PURE__ */ jsx5(
866
+ "span",
867
+ {
868
+ "aria-hidden": "true",
869
+ className: "flex size-4 items-center justify-center rounded border transition-colors duration-fast ease-standard " + (column.getIsVisible() ? "border-primary bg-primary text-primary-foreground" : "border-input"),
870
+ children: column.getIsVisible() ? "\u2713" : ""
871
+ }
872
+ ),
873
+ /* @__PURE__ */ jsx5("span", { className: "capitalize", children: column.id })
874
+ ]
875
+ },
876
+ column.id
877
+ ))
878
+ ] })
879
+ ] });
880
+ }
881
+ ColumnPickerInner.displayName = "ColumnPicker";
882
+ var ColumnPicker = forwardRef3(ColumnPickerInner);
883
+
884
+ // 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
+ }
902
+ function toCsv(rows, opts) {
903
+ const delimiter = opts?.delimiter ?? ",";
904
+ const includeHeader = opts?.header !== false;
905
+ const firstRow = rows[0];
906
+ const cols = opts?.columns ?? (firstRow !== void 0 ? Object.keys(firstRow).map((k) => ({ key: k })) : []);
907
+ const lines = [];
908
+ if (includeHeader && cols.length > 0) {
909
+ const headerRow = cols.map((c) => quoteField(c.header ?? c.key, delimiter)).join(delimiter);
910
+ lines.push(headerRow);
911
+ }
912
+ for (const row of rows) {
913
+ const line = cols.map((c) => quoteField(stringifyValue(row[c.key]), delimiter)).join(delimiter);
914
+ lines.push(line);
915
+ }
916
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
917
+ }
918
+ function downloadCsv(rows, opts) {
919
+ if (typeof document === "undefined") return;
920
+ const csv = toCsv(rows, opts);
921
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
922
+ downloadBlob(blob, (opts?.filename ?? "download") + ".csv");
923
+ }
924
+ export {
925
+ ColumnPicker,
926
+ DataTableWithRef as DataTable,
927
+ FacetFilter,
928
+ FilterBar,
929
+ SearchInput,
930
+ downloadCsv,
931
+ toCsv
932
+ };
933
+ //# sourceMappingURL=index.js.map