cursedbelt 5.25.0 → 5.26.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.
Files changed (70) hide show
  1. package/dist/react/components/Badge.d.ts +6 -6
  2. package/dist/react/components/Badge.d.ts.map +1 -1
  3. package/dist/react/components/Badge.js +13 -5
  4. package/dist/react/components/Badge.js.map +1 -1
  5. package/dist/react/components/DataTable/DataTable.d.ts +125 -5
  6. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.js +335 -121
  8. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  9. package/dist/react/components/DataTable/RowActions.d.ts +3 -1
  10. package/dist/react/components/DataTable/RowActions.d.ts.map +1 -1
  11. package/dist/react/components/DataTable/RowActions.js +9 -2
  12. package/dist/react/components/DataTable/RowActions.js.map +1 -1
  13. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  14. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  15. package/dist/react/components/DataTable/SortHeader.js +7 -1
  16. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  17. package/dist/react/components/DataTable/cells/CellRenderer.d.ts.map +1 -1
  18. package/dist/react/components/DataTable/cells/CellRenderer.js +4 -1
  19. package/dist/react/components/DataTable/cells/CellRenderer.js.map +1 -1
  20. package/dist/react/components/DataTable/index.d.ts +2 -2
  21. package/dist/react/components/DataTable/index.d.ts.map +1 -1
  22. package/dist/react/components/DataTable/index.js +1 -1
  23. package/dist/react/components/DataTable/index.js.map +1 -1
  24. package/dist/react/components/DataTable/lazyPart.d.ts +27 -0
  25. package/dist/react/components/DataTable/lazyPart.d.ts.map +1 -0
  26. package/dist/react/components/DataTable/lazyPart.js +46 -0
  27. package/dist/react/components/DataTable/lazyPart.js.map +1 -0
  28. package/dist/react/components/DataTable/types.d.ts +106 -0
  29. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  30. package/dist/react/components/DataTable/types.js.map +1 -1
  31. package/dist/react/components/auth/SignInCard.d.ts +49 -0
  32. package/dist/react/components/auth/SignInCard.d.ts.map +1 -0
  33. package/dist/react/components/auth/SignInCard.js +21 -0
  34. package/dist/react/components/auth/SignInCard.js.map +1 -0
  35. package/dist/react/components/auth/index.d.ts +1 -0
  36. package/dist/react/components/auth/index.d.ts.map +1 -1
  37. package/dist/react/components/auth/index.js +1 -0
  38. package/dist/react/components/auth/index.js.map +1 -1
  39. package/dist/react/index.d.ts +1 -1
  40. package/dist/react/index.d.ts.map +1 -1
  41. package/dist/react/index.js +1 -1
  42. package/dist/react/index.js.map +1 -1
  43. package/dist/styles-areas/analytics.css +9 -6
  44. package/dist/styles-areas/core.css +8 -7
  45. package/dist/styles-areas/data-table.css +8 -5
  46. package/dist/styles-utilities.css +75 -0
  47. package/package.json +2 -2
  48. package/src/react/components/Badge.capsule.spec.ts +178 -0
  49. package/src/react/components/Badge.tsx +13 -5
  50. package/src/react/components/DataTable/DataTable.spec.tsx +6 -2
  51. package/src/react/components/DataTable/DataTable.tsx +518 -104
  52. package/src/react/components/DataTable/RowActions.tsx +18 -10
  53. package/src/react/components/DataTable/SortHeader.tsx +19 -0
  54. package/src/react/components/DataTable/cells/CellRenderer.tsx +2 -0
  55. package/src/react/components/DataTable/index.ts +10 -1
  56. package/src/react/components/DataTable/lazyPart.spec.tsx +99 -0
  57. package/src/react/components/DataTable/lazyPart.tsx +58 -0
  58. package/src/react/components/DataTable/rowHooks.spec.tsx +289 -0
  59. package/src/react/components/DataTable/selection.spec.tsx +6 -2
  60. package/src/react/components/DataTable/tableSystem.spec.tsx +6 -2
  61. package/src/react/components/DataTable/types.ts +110 -0
  62. package/src/react/components/DataTable/virtualize.spec.tsx +38 -2
  63. package/src/react/components/auth/SignInCard.spec.tsx +48 -0
  64. package/src/react/components/auth/SignInCard.tsx +90 -0
  65. package/src/react/components/auth/index.ts +1 -0
  66. package/src/react/index.ts +5 -0
  67. package/src/styles-areas/analytics.css +9 -6
  68. package/src/styles-areas/core.css +8 -7
  69. package/src/styles-areas/data-table.css +8 -5
  70. package/src/styles-utilities.css +75 -0
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table';
3
3
  import { useVirtualizer } from '@tanstack/react-virtual';
4
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Fragment, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
5
5
  import { useScrollEdgeFade } from '../../hooks/useScrollEdgeFade.js';
6
6
  import { cn } from '../../lib/cn.js';
7
+ import { TAP_TARGET_HEIGHT } from '../../lib/tapTarget.js';
7
8
  import { CardTapTarget } from '../CardTapTarget.js';
8
9
  import { Checkbox } from '../Checkbox.js';
9
10
  import { EmptyState } from '../EmptyState.js';
@@ -13,12 +14,39 @@ import { Tooltip } from '../Tooltip.js';
13
14
  import { CardView } from './CardView.js';
14
15
  import { CellRenderer } from './cells/CellRenderer.js';
15
16
  import { EMPTY_FILTER_QUERY, evaluateFilterQuery, filterableText, } from './filterQuery.js';
16
- import { TablePagination } from './Pagination.js';
17
- import { RowActions } from './RowActions.js';
17
+ import { preloadRowMenu, RowActions } from './RowActions.js';
18
18
  import { writeRowDrag } from './rowDrag.js';
19
19
  import { SortHeader } from './SortHeader.js';
20
- import { TableToolbar } from './Toolbar.js';
21
20
  import { resolveColumnAlign } from './types.js';
21
+ import { lazyPart } from './lazyPart.js';
22
+ // 🔴 The toolbar and the pager load only when a table DRAWS them (forge task 2309).
23
+ // Between them they reach Radix's Select, Menu and Popover — ~20 kB gzip that a
24
+ // read-only list (`searchable={false}`, `serverWindow`, no row menu) never opens.
25
+ // Measured on apps/music's track list: 67 kB gzip over its entry ceiling, most of
26
+ // it these two parts' dependencies. A static import made every consumer pay for
27
+ // them. The row kebab's menu is lazy the same way, inside `RowActions`; all three
28
+ // are loaded ahead by `preloadDataTableParts`. `lazyPart` erases the component's
29
+ // type parameter, so each is given back its own signature.
30
+ const ToolbarPart = lazyPart(() => import('./Toolbar.js').then((m) => m.TableToolbar));
31
+ const PaginationPart = lazyPart(() => import('./Pagination.js').then((m) => m.TablePagination));
32
+ /**
33
+ * Load the table's lazy parts — the toolbar, the pager, the row kebab's menu — now,
34
+ * so every table mounted after this resolves draws them on its first render. For a
35
+ * spec setup that queries them synchronously after `render(…)`, and for an app about
36
+ * to show a table with a toolbar. Never required: without it they arrive a tick late,
37
+ * into a placeholder of their own height.
38
+ */
39
+ export async function preloadDataTableParts() {
40
+ await Promise.all([ToolbarPart.preload(), PaginationPart.preload(), preloadRowMenu()]);
41
+ }
42
+ /** Where the toolbar will be, the height it will be — so its arrival shifts nothing. */
43
+ function ToolbarPlaceholder() {
44
+ return (_jsx("div", { "data-cb-table-toolbar-pending": '', "aria-hidden": true, className: 'border-b border-border px-2 py-2', children: _jsx("div", { className: cn('h-9', TAP_TARGET_HEIGHT) }) }));
45
+ }
46
+ /** Where the pager will be, the height it will be. */
47
+ function PaginationPlaceholder() {
48
+ return (_jsx("div", { "data-cb-table-pagination-pending": '', "aria-hidden": true, className: 'border-t border-border px-4 py-2.5', children: _jsx("div", { className: cn('h-8', TAP_TARGET_HEIGHT) }) }));
49
+ }
22
50
  /** Build an OnChangeFn that updates local state AND notifies an optional listener. */
23
51
  function chainUpdater(set, notify) {
24
52
  return (updater) => set((prev) => {
@@ -119,15 +147,16 @@ export function declaredTrackFloor(columns, hasActions = false) {
119
147
  * been required to be stable since ES2019 and TanStack's sorted row model uses
120
148
  * it, so rows that tie on every key in the stack keep the order they arrived in.
121
149
  */
122
- export function nextSortStack(current, id, multi) {
150
+ export function nextSortStack(current, id, multi, options = {}) {
151
+ const first = options.descFirst ?? false;
123
152
  const existing = current.find((sort) => sort.id === id);
124
153
  if (!existing) {
125
- const ascending = { id, desc: false };
126
- return multi ? [ascending, ...current] : [ascending];
154
+ const started = { id, desc: first };
155
+ return multi ? [started, ...current] : [started];
127
156
  }
128
- if (!existing.desc) {
129
- const flipped = current.map((sort) => (sort.id === id ? { id, desc: true } : sort));
130
- return multi ? flipped : [{ id, desc: true }];
157
+ if (existing.desc === first || options.removable === false) {
158
+ const flipped = { id, desc: !existing.desc };
159
+ return multi ? current.map((sort) => (sort.id === id ? flipped : sort)) : [flipped];
131
160
  }
132
161
  return multi ? current.filter((sort) => sort.id !== id) : [];
133
162
  }
@@ -146,6 +175,48 @@ function readPath(row, key) {
146
175
  return value[part];
147
176
  }, row);
148
177
  }
178
+ /**
179
+ * `ColDef.hideBelow` as the container-query classes that do it. Literal strings, so the style
180
+ * generators (`scripts/generate*Styles.ts`) find them in the source.
181
+ *
182
+ * 🔴 The column COLLAPSES — zero width, no padding, its content removed — rather than
183
+ * `display: none`. A hidden cell is not a column, but the rows that SPAN the table (the
184
+ * virtualizer's spacers, a server window's placeholder rows, an expanded panel) still say
185
+ * `colSpan={every column}`, and a span wider than the table's real columns makes the missing
186
+ * ones up: they took the width the hidden columns gave back, so the table's rows stopped short
187
+ * of its right edge and the title — the one width-less column — was crushed to "S…". Measured
188
+ * on apps/music at 390px and 1100px, forge task 2302. Collapsed, the column count never moves,
189
+ * and every span stays true. (`[&>*]:hidden` takes the content out of the accessibility tree
190
+ * too, so a collapsed cell reads as nothing, not as a value nobody can see.)
191
+ */
192
+ const HIDE_BELOW_CLASS = {
193
+ sm: '@max-2xl:w-0! @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:border-0 @max-2xl:[&>*]:hidden',
194
+ md: '@max-4xl:w-0! @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:border-0 @max-4xl:[&>*]:hidden',
195
+ lg: '@max-6xl:w-0! @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:border-0 @max-6xl:[&>*]:hidden',
196
+ };
197
+ /**
198
+ * The table's own props for a row, with the caller's `getRowProps` merged on top: classes join,
199
+ * styles spread, and a handler both set runs the table's first, then the caller's.
200
+ */
201
+ export function mergeRowProps(own, extra) {
202
+ if (!extra)
203
+ return own;
204
+ const merged = { ...own, ...extra };
205
+ if (own.className || extra.className)
206
+ merged.className = cn(own.className, extra.className);
207
+ if (own.style || extra.style)
208
+ merged.style = { ...own.style, ...extra.style };
209
+ for (const [key, handler] of Object.entries(extra)) {
210
+ const mine = own[key];
211
+ if (!key.startsWith('on') || typeof handler !== 'function' || typeof mine !== 'function')
212
+ continue;
213
+ merged[key] = (event) => {
214
+ mine(event);
215
+ handler(event);
216
+ };
217
+ }
218
+ return merged;
219
+ }
149
220
  /** True when a cell value carries nothing worth a column: null/undefined/''/[]. */
150
221
  function isEmptyCellValue(v) {
151
222
  if (v == null)
@@ -169,11 +240,15 @@ function isEmptyCellValue(v) {
169
240
  * full-width accordion detail row that never breaks column alignment, and an automatic
170
241
  * stacked-card layout under `sm` so phones are never handed a clipped grid.
171
242
  */
172
- export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRowDelete, rowMenu, loading, emptyState, toolbar, pageSize = 50, pageSizeOptions, hidePagination, caption, className, stickyHeader, stickyFirstColumn, renderExpanded, expandedRowId, rowClassName, hideEmptyColumns, fixedLayout, minTableWidth, scrollBody, virtualize, mobileCards = true, serverSide, pageCount, rowCount, onSortingChange, onColumnFiltersChange, onGlobalFilterChange, onPaginationChange, defaultSort, density = 'comfortable', viewMode, onViewModeChange, cardMinWidth, searchable = true, searchPlaceholder, multiSort = true, filterQuery: filterQueryProp, onFilterQueryChange, getRowDrag, dragSourceId, selectedIds, onSelectionChange, isRowSelectable, unselectableReason, selectionLabel, selectionSummary, }) {
243
+ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRowDelete, rowMenu, loading, emptyState, toolbar, pageSize = 50, pageSizeOptions, hidePagination, caption, className, stickyHeader, stickyFirstColumn, renderExpanded, expandedRowId, rowClassName, hideEmptyColumns, fixedLayout, minTableWidth, scrollBody, virtualize, serverWindow, handleRef, scrollClassName, mobileCards = true, serverSide, pageCount, rowCount, onSortingChange, onColumnFiltersChange, onGlobalFilterChange, onPaginationChange, defaultSort, sorting: sortingProp, enableSortingRemoval = true, sortDisabledReason, density = 'comfortable', viewMode, onViewModeChange, cardMinWidth, searchable = true, searchPlaceholder, multiSort = true, filterQuery: filterQueryProp, onFilterQueryChange, getRowDrag, dragSourceId, selectedIds, onSelectionChange, isRowSelectable, unselectableReason, selectionLabel, selectionSummary, selectAll, getRowProps, }) {
173
244
  // Vertical cell padding for the wide grid tier — the one lever the density knob
174
245
  // pulls (headers + body cells stay aligned by sharing it).
175
246
  const cellPadY = density === 'compact' ? 'py-1.5' : 'py-2.5';
176
- const [sorting, setSorting] = useState(defaultSort ?? []);
247
+ const [ownSorting, setOwnSorting] = useState(defaultSort ?? []);
248
+ const sorting = sortingProp ?? ownSorting;
249
+ /** The sort as of THIS render, for a write that has to start from it (see `applySorting`). */
250
+ const sortingRef = useRef(sorting);
251
+ sortingRef.current = sorting;
177
252
  const [columnFilters, setColumnFilters] = useState([]);
178
253
  const [globalFilter, setGlobalFilter] = useState('');
179
254
  const [columnVisibility, setColumnVisibility] = useState(() => Object.fromEntries(columns.filter((c) => c.hidden).map((c) => [c.key, false])));
@@ -227,14 +302,17 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
227
302
  * server-side table ends up sorted differently from what its chips say.
228
303
  */
229
304
  const applySorting = useCallback((next) => {
230
- setSorting((prev) => {
231
- const value = next(prev);
232
- setPagination((p) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }));
233
- onSortingChange?.(value);
234
- return value;
235
- });
236
- }, [onSortingChange]);
237
- const toggleSortKey = useCallback((id) => applySorting((prev) => nextSortStack(prev, id, multiSort)), [applySorting, multiSort]);
305
+ // Computed OUTSIDE a state updater: under a controlled `sorting` there is no state to
306
+ // update, and a notify inside an updater fires twice in StrictMode.
307
+ const value = next(sortingRef.current);
308
+ sortingRef.current = value;
309
+ if (sortingProp === undefined)
310
+ setOwnSorting(value);
311
+ setPagination((p) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }));
312
+ onSortingChange?.(value);
313
+ }, [onSortingChange, sortingProp]);
314
+ const sortDescFirstOf = useCallback((id) => columns.find((col) => col.key === id)?.sortDescFirst ?? false, [columns]);
315
+ const toggleSortKey = useCallback((id) => applySorting((prev) => nextSortStack(prev, id, multiSort, { descFirst: sortDescFirstOf(id), removable: enableSortingRemoval })), [applySorting, enableSortingRemoval, multiSort, sortDescFirstOf]);
238
316
  const tsColumns = useMemo(() => liveColumns.map((col) => ({
239
317
  id: col.key,
240
318
  accessorKey: col.key,
@@ -245,6 +323,8 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
245
323
  align: resolveColumnAlign(col),
246
324
  width: col.width,
247
325
  minWidth: col.minWidth,
326
+ hideBelow: col.hideBelow,
327
+ leading: col.leading,
248
328
  },
249
329
  size: typeof col.width === 'number' ? col.width : undefined,
250
330
  enableSorting: col.sortable ?? false,
@@ -266,10 +346,10 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
266
346
  // TanStack's column instances mid-interaction.
267
347
  header: ({ column, table: t }) => {
268
348
  const stack = t.getState().sorting;
269
- return (_jsx(SortHeader, { column: column, label: col.label, sortable: col.sortable, sortIndex: stack.length > 1 ? stack.findIndex((s) => s.id === col.key) : -1, onSort: () => toggleSortKey(col.key) }));
349
+ return (_jsx(SortHeader, { column: column, label: col.label, sortable: col.sortable, sortIndex: stack.length > 1 ? stack.findIndex((s) => s.id === col.key) : -1, onSort: () => toggleSortKey(col.key), disabledReason: sortDisabledReason }));
270
350
  },
271
351
  cell: ({ getValue, row }) => (_jsx(CellRenderer, { col: col, value: getValue(), row: row.original, onEdit: onRowEdit ? () => onRowEdit(row.original) : undefined })),
272
- })), [liveColumns, onRowEdit, toggleSortKey]);
352
+ })), [liveColumns, onRowEdit, sortDisabledReason, toggleSortKey]);
273
353
  const labelOf = useCallback((id) => liveColumns.find((col) => col.key === id)?.label ?? id, [liveColumns]);
274
354
  const controls = useMemo(() => ({
275
355
  searchText: globalFilter,
@@ -321,7 +401,7 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
321
401
  * failure the pager's absence hides rather than reveals, which is why it is tested by
322
402
  * measuring `scrollHeight` in a real browser.
323
403
  */
324
- const virtualOptions = virtualize === true ? {} : virtualize || null;
404
+ const virtualOptions = virtualize === true || (serverWindow && !virtualize) ? {} : virtualize || null;
325
405
  const virtualEnabled = virtualOptions !== null;
326
406
  const bodyScrolls = scrollBody || virtualEnabled;
327
407
  const paginationHidden = hidePagination || virtualEnabled;
@@ -340,7 +420,9 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
340
420
  columns: tsColumns,
341
421
  getRowId: resolvedGetRowId,
342
422
  state: { sorting, columnFilters, globalFilter: search, columnVisibility, pagination },
343
- onSortingChange: chainUpdaterResettingPage(setSorting, setPagination, onSortingChange),
423
+ // Through `applySorting`, like every other write — so a controlled `sorting` is honoured
424
+ // whether TanStack or a header asked for the change.
425
+ onSortingChange: (updater) => applySorting((prev) => (typeof updater === 'function' ? updater(prev) : updater)),
344
426
  onColumnFiltersChange: chainUpdaterResettingPage(setColumnFilters, setPagination, onColumnFiltersChange),
345
427
  // TanStack hands the updater whatever `state.globalFilter` is, which is now
346
428
  // a TableSearchState; the toolbar sets TEXT, so normalise both shapes.
@@ -368,14 +450,16 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
368
450
  return false;
369
451
  return filterableText(row.getValue(columnId)).toLowerCase().includes(text.toLowerCase());
370
452
  },
371
- manualSorting: serverSide,
372
- manualFiltering: serverSide,
453
+ // A server window is server-ordered and server-filtered by construction: a WINDOW
454
+ // re-sorted locally would put row 4,000's neighbours next to row 12's.
455
+ manualSorting: serverSide || !!serverWindow,
456
+ manualFiltering: serverSide || !!serverWindow,
373
457
  manualPagination: serverSide,
374
458
  pageCount: serverSide ? pageCount : undefined,
375
459
  rowCount: serverSide ? rowCount : undefined,
376
460
  getCoreRowModel: getCoreRowModel(),
377
- getSortedRowModel: serverSide ? undefined : getSortedRowModel(),
378
- getFilteredRowModel: serverSide ? undefined : getFilteredRowModel(),
461
+ getSortedRowModel: serverSide || serverWindow ? undefined : getSortedRowModel(),
462
+ getFilteredRowModel: serverSide || serverWindow ? undefined : getFilteredRowModel(),
379
463
  // 🔴 `virtualEnabled` too, not only `serverSide`. See the note where the flag is
380
464
  // computed: a paginated virtualizer sizes its spacer from one PAGE.
381
465
  getPaginationRowModel: serverSide || virtualEnabled ? undefined : getPaginationRowModel(),
@@ -413,17 +497,94 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
413
497
  };
414
498
  };
415
499
  const rows = table.getRowModel().rows;
416
- const estimateRowHeight = virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44);
500
+ /**
501
+ * 🔴 A {@link serverWindow} row is FIXED-height, never measured. A measured list places
502
+ * every unloaded row at the estimate and every loaded one at its real height, so the page
503
+ * that arrives above the viewport re-sizes a hundred rows and moves everything under the
504
+ * reader — measured in the gate: a smooth reveal of row 4,000 that paged in the rows above
505
+ * it on arrival came to rest thirteen rows short. apps/music's own list held every row at
506
+ * one height for the same reason ("Fixed row height is what makes the arithmetic jitter-free").
507
+ */
508
+ const fixedRowHeight = serverWindow
509
+ ? (serverWindow.rowHeight ?? virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44))
510
+ : null;
511
+ const estimateRowHeight = fixedRowHeight ?? virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44);
417
512
  const scrollRef = useRef(null);
513
+ /** Rows before the loaded window, and the size of the whole result set — both 0/`rows.length`
514
+ * without a {@link serverWindow}, which makes every index below the same number as before. */
515
+ const windowStart = serverWindow ? Math.max(0, serverWindow.start) : 0;
516
+ const windowTotal = serverWindow ? Math.max(serverWindow.total, windowStart + rows.length) : rows.length;
517
+ /** The caller's window callbacks, read at FIRE time: by the time a settle timer fires the
518
+ * window may have moved, and the fresh handler is the one that knows. */
519
+ const windowRef = useRef(serverWindow);
520
+ windowRef.current = serverWindow;
521
+ const loadedRef = useRef({ start: windowStart, end: windowStart + rows.length, total: windowTotal });
522
+ loadedRef.current = { start: windowStart, end: windowStart + rows.length, total: windowTotal };
523
+ const settleRef = useRef(null);
524
+ /** The `scrollTop` the last `onChange` saw. The virtualizer also calls `onChange` when its
525
+ * is-scrolling flag resets and on every re-render; only a MOVE may arm the settle timer, or
526
+ * the flag's own reset re-asks for the window that was just requested (measured: twice). */
527
+ const lastTopRef = useRef(null);
528
+ useEffect(() => () => {
529
+ if (settleRef.current)
530
+ clearTimeout(settleRef.current);
531
+ }, []);
418
532
  const rowVirtualizer = useVirtualizer({
419
- count: virtualEnabled ? rows.length : 0,
533
+ count: virtualEnabled ? windowTotal : 0,
420
534
  getScrollElement: () => scrollRef.current,
421
535
  estimateSize: () => estimateRowHeight,
422
536
  overscan: virtualOptions?.overscan ?? 8,
537
+ onChange: serverWindow
538
+ ? (instance) => {
539
+ const win = windowRef.current;
540
+ const element = instance.scrollElement;
541
+ if (!win || !element)
542
+ return;
543
+ const { start, end, total } = loadedRef.current;
544
+ // Rows over the whole result set: the first one on screen, and the one after the last.
545
+ const items = instance.getVirtualItems();
546
+ if (items.length === 0)
547
+ return;
548
+ const top = element.scrollTop;
549
+ const bottom = top + element.clientHeight;
550
+ const firstShown = items.find((item) => item.end > top)?.index ?? items[0].index;
551
+ const lastShown = (items.findLast((item) => item.start < bottom)?.index ?? items.at(-1).index) + 1;
552
+ const moved = lastTopRef.current !== top;
553
+ lastTopRef.current = top;
554
+ // Nothing on screen is loaded. Hand the destination over once the scroll RESTS,
555
+ // and do not also ask for a page — it would load rows nowhere near the reader.
556
+ if (firstShown >= end || lastShown <= start) {
557
+ if (!moved)
558
+ return;
559
+ if (settleRef.current)
560
+ clearTimeout(settleRef.current);
561
+ settleRef.current = setTimeout(() => {
562
+ settleRef.current = null;
563
+ // Read at FIRE time: the window may have arrived while the scroll rested.
564
+ const loaded = loadedRef.current;
565
+ if (firstShown < loaded.end && lastShown > loaded.start)
566
+ return;
567
+ windowRef.current?.onNeedWindow?.(firstShown);
568
+ }, win.settleMs ?? 120);
569
+ return;
570
+ }
571
+ if (settleRef.current) {
572
+ clearTimeout(settleRef.current);
573
+ settleRef.current = null;
574
+ }
575
+ // Against the end of the LOADED window, never the spacer's — see the prop doc.
576
+ if (win.onReachEnd && end < total && end - lastShown < (win.reachRows ?? 20))
577
+ win.onReachEnd();
578
+ // Tighter, and only off row 0: a reveal lands a few rows into a fresh window, and a
579
+ // generous trigger here would fire on arrival and cascade back to the top.
580
+ if (win.onReachStart && start > 0 && firstShown - start < (win.reachRowsUp ?? 8))
581
+ win.onReachStart();
582
+ }
583
+ : undefined,
423
584
  // 🔴 MEASURED, not assumed. A wrapping cell, an expanded detail panel and the
424
585
  // `compact` density all change a row's height, and a caller cannot be asked to
425
586
  // compute any of them. `estimateSize` only seeds the spacer before first paint.
426
- measureElement: (el) => el.getBoundingClientRect().height,
587
+ measureElement: (el) => fixedRowHeight ?? el.getBoundingClientRect().height,
427
588
  });
428
589
  /**
429
590
  * 🔴 Invalidate the measurement cache when the row height changes.
@@ -447,8 +608,27 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
447
608
  }, [rowVirtualizer, estimateRowHeight]);
448
609
  const virtualRows = virtualEnabled ? rowVirtualizer.getVirtualItems() : [];
449
610
  /** The rows to render, and the two spacers that keep the scrollbar honest about the
450
- * ones that are not. Off, this is every row and no spacers — byte-identical output. */
451
- const bodyRows = virtualEnabled ? virtualRows.map((v) => rows[v.index]) : rows;
611
+ * ones that are not. Off, this is every row and no spacers — byte-identical output.
612
+ * `null` is a row a {@link serverWindow} has not loaded: a placeholder of the estimated
613
+ * height, so the window can land anywhere in the spacer before its rows arrive. */
614
+ const bodyRows = virtualEnabled
615
+ ? virtualRows.map((v) => rows[v.index - windowStart] ?? null)
616
+ : rows;
617
+ useImperativeHandle(handleRef, () => ({
618
+ get scrollElement() {
619
+ return scrollRef.current;
620
+ },
621
+ scrollToIndex(index, options = {}) {
622
+ const element = scrollRef.current;
623
+ if (!element || !virtualEnabled)
624
+ return null;
625
+ const clamped = Math.max(0, Math.min(index, loadedRef.current.total - 1));
626
+ const [offset] = rowVirtualizer.getOffsetForIndex(clamped, options.align ?? 'center') ?? [clamped * estimateRowHeight];
627
+ const top = Math.max(0, Math.round(offset));
628
+ element.scrollTo({ top, behavior: options.behavior ?? 'auto' });
629
+ return top;
630
+ },
631
+ }), [estimateRowHeight, rowVirtualizer, virtualEnabled]);
452
632
  const padTop = virtualEnabled ? (virtualRows[0]?.start ?? 0) : 0;
453
633
  const padBottom = virtualEnabled
454
634
  ? rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0)
@@ -534,7 +714,7 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
534
714
  // Enter-to-open handler.
535
715
  onKeyDown: (event) => event.stopPropagation(),
536
716
  });
537
- const headerCheckbox = (label) => (_jsx(Checkbox, { checked: headerChecked, disabled: selectableFilteredIds.length === 0, onCheckedChange: toggleAllSelected, "aria-label": selectAllLabel, label: label, labelClassName: label == null ? undefined : 'text-xs text-muted-foreground' }));
717
+ const headerCheckbox = (label) => selectAll ? (_jsx(Tooltip, { content: selectAll.label, children: _jsx("span", { className: 'inline-flex', children: _jsx(Checkbox, { checked: selectAll.checked, disabled: selectAll.disabled, onCheckedChange: (checked) => selectAll.onChange(checked === true), "aria-label": selectAll.label, label: label, labelClassName: label == null ? undefined : 'text-xs text-muted-foreground' }) }) })) : (_jsx(Checkbox, { checked: headerChecked, disabled: selectableFilteredIds.length === 0, onCheckedChange: toggleAllSelected, "aria-label": selectAllLabel, label: label, labelClassName: label == null ? undefined : 'text-xs text-muted-foreground' }));
538
718
  const summaryNode = selectedIds && selectionSummary
539
719
  ? selectionSummary(resolvedGetRowId
540
720
  ? data.filter((row) => selectedIds.has(resolvedGetRowId(row)))
@@ -550,7 +730,7 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
550
730
  // passes its own gets exactly that, and `TableToolbar` reads the same
551
731
  // controls off `table.options.meta`, so composing a toolbar is never
552
732
  // the thing that costs you the search box.
553
- (searchable ? _jsx(TableToolbar, { table: table, searchPlaceholder: searchPlaceholder }) : null));
733
+ (searchable ? (_jsx(ToolbarPart, { table: table, searchPlaceholder: searchPlaceholder, fallback: _jsx(ToolbarPlaceholder, {}) })) : null));
554
734
  const visibleColumnCount = table.getVisibleLeafColumns().length + (hasActions ? 1 : 0) + (selectionEnabled ? 1 : 0);
555
735
  // Cards track the SAME live visibility state as the grid (ViewOptionsMenu toggles
556
736
  // apply to both) — not just the ColDefs' initial `hidden` flags.
@@ -566,12 +746,14 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
566
746
  const tableMinWidth = fixedLayout
567
747
  ? Math.max(minTableWidth ?? 0, declaredTrackFloor(cardColumns, hasActions) + (selectionEnabled ? SELECT_TRACK_PX : 0))
568
748
  : minTableWidth;
569
- return (_jsxs("div", { className: cn('@container flex min-h-0 flex-col', className), children: [toolbarNode, summaryNode != null ? (_jsx("div", { "data-cb-table-selection-summary": '', "aria-live": 'polite', className: 'flex min-h-11 flex-wrap items-center gap-3 border-b border-border px-4 py-2 text-sm', children: summaryNode })) : null, loading ? (_jsx("div", { className: 'flex flex-col gap-2 p-4', children: Array.from({ length: 5 }).map((_, i) => (_jsx(Skeleton, { className: 'h-10 w-full' }, `dt-skeleton-${i}`))) })) : rows.length === 0 ? (_jsx("div", { className: 'p-2', children: emptyState ?? _jsx(EmptyState, { title: 'No results' }) })) : (_jsxs("div", { ref: scrollRef, className: cn(bodyScrolls && 'min-h-0 flex-1 overflow-auto'), children: [selectionEnabled && (viewMode === 'card' || mobileCards) ? (_jsx("div", { "data-cb-table-select-all": '', className: cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden'), children: headerCheckbox('Select all') })) : null, viewMode === 'card' ? (_jsx(CardView, { columns: cardColumns, data: rows.map((row) => row.original), getRowId: getRowId, onRowClick: onRowClick, dragPropsFor: getRowDrag ? dragPropsFor : undefined, selectFor: selectionEnabled
749
+ return (_jsxs("div", { className: cn('@container flex min-h-0 flex-col', className), children: [toolbarNode, summaryNode != null ? (_jsx("div", { "data-cb-table-selection-summary": '', "aria-live": 'polite', className: 'flex min-h-11 flex-wrap items-center gap-3 border-b border-border px-4 py-2 text-sm', children: summaryNode })) : null, loading ? (_jsx("div", { className: 'flex flex-col gap-2 p-4', children: Array.from({ length: 5 }).map((_, i) => (_jsx(Skeleton, { className: 'h-10 w-full' }, `dt-skeleton-${i}`))) })) : rows.length === 0 && windowTotal === 0 ? (_jsx("div", { className: 'p-2', children: emptyState ?? _jsx(EmptyState, { title: 'No results' }) })) : (_jsxs("div", { ref: scrollRef, "data-cb-table-scroll": bodyScrolls ? '' : undefined,
750
+ // guardrails-ignore no-raw-overflow: dual-axis table scroll container — sticky header pins to it; ScrollArea cannot own horizontal table scroll.
751
+ className: cn(bodyScrolls && 'min-h-0 flex-1 overflow-auto', scrollClassName), children: [selectionEnabled && (viewMode === 'card' || mobileCards) ? (_jsx("div", { "data-cb-table-select-all": '', className: cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden'), children: headerCheckbox('Select all') })) : null, viewMode === 'card' ? (_jsx(CardView, { columns: cardColumns, data: rows.map((row) => row.original), getRowId: getRowId, onRowClick: onRowClick, dragPropsFor: getRowDrag ? dragPropsFor : undefined, selectFor: selectionEnabled
570
752
  ? (_row, index) => {
571
753
  const tsRow = rows[index];
572
754
  return tsRow ? rowCheckbox(tsRow) : null;
573
755
  }
574
- : undefined, cardMinWidth: cardMinWidth, className: 'p-2' })) : (_jsxs(_Fragment, { children: [mobileCards && (_jsx("div", { "data-cb-table-cards": '', className: 'flex flex-col gap-3 @xl:hidden', children: rows.map((row) => (_jsx(MobileRowCard, { row: row, columns: cardColumns, className: rowClassName?.(row.original), onRowClick: onRowClick, dragProps: dragPropsFor(row.original), select: selectionEnabled ? rowCheckbox(row) : undefined, actions: hasActions ? (_jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) })) : null, expanded: renderExpanded && expandedRowId != null && row.id === expandedRowId
756
+ : undefined, cardMinWidth: cardMinWidth, className: 'p-2' })) : (_jsxs(_Fragment, { children: [mobileCards && (_jsx("div", { "data-cb-table-cards": '', className: 'flex flex-col gap-3 @xl:hidden', children: rows.map((row) => (_jsx(MobileRowCard, { row: row, columns: cardColumns, className: rowClassName?.(row.original), onRowClick: onRowClick, dragProps: mergeRowProps(dragPropsFor(row.original), getRowProps?.(row.original, windowStart + row.index)), select: selectionEnabled ? rowCheckbox(row) : undefined, actions: hasActions ? (_jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) })) : null, expanded: renderExpanded && expandedRowId != null && row.id === expandedRowId
575
757
  ? renderExpanded(row.original)
576
758
  : null }, row.id))) })), mobileCards && (_jsx("div", { "data-cb-table-list": '', className: 'hidden @xl:block @3xl:hidden', children: (() => {
577
759
  const [title, ...rest] = cardColumns;
@@ -601,44 +783,59 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
601
783
  const header = (_jsxs("div", { "data-cb-table-list-header": '', className: 'grid items-center gap-3 border-b border-border px-4 py-2 text-caption font-bold uppercase tracking-wider text-muted-foreground', style: { gridTemplateColumns: template }, children: [selectionEnabled ? _jsx("span", { className: 'flex justify-center', children: headerCheckbox() }) : null, _jsx("span", { children: title?.label ?? '' }), rest.map((col) => (_jsx("span", { className: cn(resolveColumnAlign(col) === 'right' && 'text-right'), children: col.label }, col.key))), hasActions ? _jsx("span", { "aria-hidden": true }) : null] }));
602
784
  const body = rows.map((row) => {
603
785
  const isExpanded = renderExpanded != null && expandedRowId != null && row.id === expandedRowId;
604
- return (_jsxs(Fragment, { children: [_jsxs("div", { ...dragPropsFor(row.original), className: cn('grid items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40', getRowDrag && 'cursor-grab active:cursor-grabbing', rowClassName?.(row.original)), style: { gridTemplateColumns: template }, children: [selectionEnabled ? (_jsx("div", { ...selectCellProps(row), className: '-my-3 flex self-stretch items-center justify-center', children: rowCheckbox(row) })) : null, _jsx("div", { className: 'min-w-0', children: title ? (_jsx(CellRenderer, { col: title, value: row.original[title.key], row: row.original })) : null }), rest.map((col) => (_jsx("div", { className: cn(resolveColumnAlign(col) === 'right' && 'justify-self-end text-right'), children: _jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }) }, col.key))), hasActions ? (_jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) })) : null] }), isExpanded ? (_jsx("div", { className: 'bg-muted/20 px-4 pt-1 pb-3', children: _jsx("div", { className: 'rounded-lg border border-border bg-card p-3 shadow-raised', children: renderExpanded(row.original) }) })) : null] }, row.id));
786
+ return (_jsxs(Fragment, { children: [_jsxs("div", { ...mergeRowProps({
787
+ ...dragPropsFor(row.original),
788
+ className: cn('grid items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40', getRowDrag && 'cursor-grab active:cursor-grabbing', rowClassName?.(row.original)),
789
+ style: { gridTemplateColumns: template },
790
+ }, getRowProps?.(row.original, windowStart + row.index)), children: [selectionEnabled ? (_jsx("div", { ...selectCellProps(row), className: '-my-3 flex self-stretch items-center justify-center', children: rowCheckbox(row) })) : null, _jsx("div", { className: 'min-w-0', children: title ? (_jsx(CellRenderer, { col: title, value: row.original[title.key], row: row.original })) : null }), rest.map((col) => (_jsx("div", { className: cn(resolveColumnAlign(col) === 'right' && 'justify-self-end text-right'), children: _jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }) }, col.key))), hasActions ? (_jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) })) : null] }), isExpanded ? (_jsx("div", { className: 'bg-muted/20 px-4 pt-1 pb-3', children: _jsx("div", { className: 'rounded-lg border border-border bg-card p-3 shadow-raised', children: renderExpanded(row.original) }) })) : null] }, row.id));
605
791
  });
606
792
  return (_jsxs(_Fragment, { children: [header, _jsx("div", { className: 'divide-y divide-border', children: body })] }));
607
793
  })() })), _jsx("div", { ref: bodyScrolls ? undefined : gridFade.ref, onScroll: bodyScrolls ? undefined : gridFade.onScroll, style: bodyScrolls ? undefined : gridFade.maskStyle,
608
794
  // guardrails-ignore no-raw-overflow: content-sized horizontal table scroll, not a page-level vertical scroll region — see reasoning above. Under scrollBody the OUTER wrapper owns both axes (a nested overflow ancestor would un-stick the sticky header).
609
- className: cn(!bodyScrolls && 'overflow-x-auto', mobileCards && 'hidden @3xl:block'), children: _jsxs("table", { className: cn('w-full border-collapse text-sm', fixedLayout && 'table-fixed'), style: tableMinWidth != null ? { minWidth: tableMinWidth } : undefined, children: [caption ? _jsx("caption", { className: 'sr-only', children: caption }) : null, _jsx("thead", { children: table.getHeaderGroups().map((hg) => (_jsxs("tr", { children: [selectionEnabled ? (_jsx("th", { scope: 'col', className: cn('w-11 border-b border-border px-0 text-center', stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm', stickyFirstColumn && 'sticky left-0 bg-card', stickyFirstColumn && (stickyHeader ? 'z-30' : 'z-20')), children: headerCheckbox() })) : null, hg.headers.map((h, headerIndex) => {
610
- const sorted = h.column.getIsSorted();
611
- const meta = h.column.columnDef.meta;
612
- const width = meta?.width ?? h.column.columnDef.size;
613
- const align = meta?.align;
614
- const frozen = stickyFirstColumn === true && headerIndex === 0;
615
- return (_jsx("th", { scope: 'col', "aria-sort": sorted === 'asc'
616
- ? 'ascending'
617
- : sorted === 'desc'
618
- ? 'descending'
619
- : undefined, style: width != null || meta?.minWidth != null
620
- ? { width, minWidth: meta?.minWidth }
621
- : undefined, className: cn(
622
- // Metadata treatment: headers are chrome, not data — small,
623
- // bold, uppercase, tracked, muted (STANDARDS §5c) — via the
624
- // explicit secondary token, never an opacity fade (the
625
- // design manual bans opacity-blended text). The border
626
- // rides the <th> (not the <tr>) so it pins with stickyHeader.
627
- 'border-b border-border px-4 text-caption font-bold uppercase tracking-wider text-muted-foreground', cellPadY, align === 'right' ? 'text-right' : 'text-left',
628
- // Sticky pins per-<th> (a <tr> can't stick); needs its own
629
- // near-opaque bg so rows scrolling beneath never bleed through.
630
- stickyHeader &&
631
- 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
632
- // The corner cell must outrank BOTH pinned axes, or the
633
- // next header slides over the frozen one. It is opaque
634
- // for the same reason the body cells are.
635
- frozen &&
636
- (selectionEnabled
637
- ? 'sticky left-11 bg-card shadow-[1px_0_0_0_var(--color-border)]'
638
- : 'sticky left-0 bg-card shadow-[1px_0_0_0_var(--color-border)]'), frozen && (stickyHeader ? 'z-30' : 'z-20')), children: h.isPlaceholder
639
- ? null
640
- : flexRender(h.column.columnDef.header, h.getContext()) }, h.id));
641
- }), hasActions ? (_jsx("th", { className: cn('w-20 border-b border-border px-2', stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm'), "aria-label": 'Row actions' })) : null] }, hg.id))) }), _jsxs("tbody", { children: [padTop > 0 ? (_jsx("tr", { "aria-hidden": true, style: { height: padTop }, children: _jsx("td", { colSpan: visibleColumnCount }) })) : null, bodyRows.map((row, sliceIndex) => {
795
+ className: cn(!bodyScrolls && 'overflow-x-auto', mobileCards && 'hidden @3xl:block'), children: _jsxs("table", { className: cn('w-full border-collapse text-sm', fixedLayout && 'table-fixed'), style: tableMinWidth != null ? { minWidth: tableMinWidth } : undefined, children: [caption ? _jsx("caption", { className: 'sr-only', children: caption }) : null, _jsx("thead", { children: table.getHeaderGroups().map((hg) => (_jsxs("tr", { children: [(() => {
796
+ // `ColDef.leading` columns sit BEFORE the checkbox column; see its doc.
797
+ const renderHeader = (h, headerIndex) => {
798
+ const sorted = h.column.getIsSorted();
799
+ const meta = h.column.columnDef.meta;
800
+ const width = meta?.width ?? h.column.columnDef.size;
801
+ const align = meta?.align;
802
+ const frozen = stickyFirstColumn === true && headerIndex === 0;
803
+ return (_jsx("th", { scope: 'col', "aria-sort": sorted === 'asc'
804
+ ? 'ascending'
805
+ : sorted === 'desc'
806
+ ? 'descending'
807
+ : undefined, style: width != null || meta?.minWidth != null
808
+ ? { width, minWidth: meta?.minWidth }
809
+ : undefined, className: cn(
810
+ // Metadata treatment: headers are chrome, not data — small,
811
+ // bold, uppercase, tracked, muted (STANDARDS §5c) — via the
812
+ // explicit secondary token, never an opacity fade (the
813
+ // design manual bans opacity-blended text). The border
814
+ // rides the <th> (not the <tr>) so it pins with stickyHeader.
815
+ 'border-b border-border px-4 text-caption font-bold uppercase tracking-wider text-muted-foreground', cellPadY, align === 'right' ? 'text-right' : 'text-left',
816
+ // Sticky pins per-<th> (a <tr> can't stick); needs its own
817
+ // near-opaque bg so rows scrolling beneath never bleed through.
818
+ stickyHeader &&
819
+ 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
820
+ // The corner cell must outrank BOTH pinned axes, or the
821
+ // next header slides over the frozen one. It is opaque
822
+ // for the same reason the body cells are.
823
+ frozen &&
824
+ (selectionEnabled
825
+ ? 'sticky left-11 bg-card shadow-[1px_0_0_0_var(--color-border)]'
826
+ : 'sticky left-0 bg-card shadow-[1px_0_0_0_var(--color-border)]'), frozen && (stickyHeader ? 'z-30' : 'z-20'), meta?.hideBelow && HIDE_BELOW_CLASS[meta.hideBelow]), children: h.isPlaceholder
827
+ ? null
828
+ : flexRender(h.column.columnDef.header, h.getContext()) }, h.id));
829
+ };
830
+ const isLeading = (h) => h.column.columnDef.meta?.leading === true;
831
+ return (_jsxs(_Fragment, { children: [hg.headers.filter(isLeading).map((h) => renderHeader(h, -1)), selectionEnabled ? (_jsx("th", { scope: 'col', className: cn('w-11 border-b border-border px-0 text-center', stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm', stickyFirstColumn && 'sticky left-0 bg-card', stickyFirstColumn && (stickyHeader ? 'z-30' : 'z-20')), children: headerCheckbox() })) : null, hg.headers.filter((h) => !isLeading(h)).map(renderHeader)] }));
832
+ })(), hasActions ? (_jsx("th", { className: cn('w-20 border-b border-border px-2', stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm'), "aria-label": 'Row actions' })) : null] }, hg.id))) }), _jsxs("tbody", { children: [padTop > 0 ? (_jsx("tr", { "aria-hidden": true, style: { height: padTop }, children: _jsx("td", { colSpan: visibleColumnCount }) })) : null, bodyRows.map((row, sliceIndex) => {
833
+ if (row === null) {
834
+ // A row the server window has not loaded yet: the estimated height, so
835
+ // the spacer arithmetic holds, and a skeleton line rather than a gap.
836
+ const index = virtualRows[sliceIndex].index;
837
+ return (_jsx("tr", { "data-index": index, "data-cb-placeholder": '', "aria-hidden": true, className: cn('border-b border-border', index % 2 === 1 && 'bg-muted/50'), style: { height: estimateRowHeight }, children: _jsx("td", { colSpan: visibleColumnCount, className: 'px-3', children: _jsx(Skeleton, { className: 'h-3', style: { width: '40%' } }) }) }, `cb-placeholder-${index}`));
838
+ }
642
839
  /* 🔴 The ZEBRA index is the row's ABSOLUTE position, not its
643
840
  position in the rendered slice. Off by that, the stripes
644
841
  would repaint themselves every time the window moved — the
@@ -646,61 +843,78 @@ export function DataTable({ columns, data, getRowId, onRowClick, onRowEdit, onRo
646
843
  fault rather than as a guide. */
647
844
  const rowIndex = virtualEnabled ? virtualRows[sliceIndex].index : sliceIndex;
648
845
  const isExpanded = renderExpanded != null && expandedRowId != null && row.id === expandedRowId;
649
- return (_jsxs(Fragment, { children: [_jsxs("tr", { ...(virtualEnabled
650
- ? { ref: rowVirtualizer.measureElement, 'data-index': rowIndex }
651
- : {}), "data-expanded": isExpanded || undefined, ...dragPropsFor(row.original), className: cn(getRowDrag && 'cursor-grab active:cursor-grabbing',
652
- // Always-on hover wash: wide rows need a trace line for the
653
- // eye even when the row itself isn't clickable. Zebra fill on
654
- // every second row gives the eye a guide path across wide
655
- // columns (design manual §4 zebra-vs-outline rule) — indexed
656
- // off the DATA row, not DOM position, so an expansion row
657
- // never flips the rhythm.
658
- 'border-b border-border transition-colors last:border-0 hover:bg-muted', rowIndex % 2 === 1 && 'bg-muted/50', rowClassName?.(row.original), isExpanded && 'border-0 bg-muted/30', onRowClick &&
659
- 'cursor-pointer hover:bg-muted focus-visible:bg-muted focus-visible:outline-none'), tabIndex: onRowClick ? 0 : undefined, onClick: onRowClick ? () => onRowClick(row.original) : undefined, onKeyDown: onRowClick
660
- ? (e) => {
661
- if (e.key === 'Enter')
662
- onRowClick(row.original);
663
- }
664
- : undefined, children: [selectionEnabled ? (_jsx("td", { ...selectCellProps(row), style: stickyFirstColumn
665
- ? {
666
- '--cb-frozen-bg': rowIndex % 2 === 1
667
- ? 'color-mix(in srgb, var(--color-muted) 50%, var(--color-card))'
668
- : 'var(--color-card)',
669
- }
670
- : undefined, className: cn('w-11 cursor-pointer px-0 text-center align-middle', stickyFirstColumn && 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)]'), children: rowCheckbox(row) })) : null, row.getVisibleCells().map((cell, cellIndex) => {
671
- const frozen = stickyFirstColumn === true && cellIndex === 0;
672
- return (_jsx("td", { style: frozen
673
- ? {
674
- // The zebra step, made OPAQUE — see the prop doc.
675
- // A custom property rather than `background:`
676
- // directly, so the value survives any CSS parser
677
- // that has not learned `color-mix` (happy-dom) and
678
- // the gate can read it back.
679
- '--cb-frozen-bg': rowIndex % 2 === 1
680
- ? 'color-mix(in srgb, var(--color-muted) 50%, var(--color-card))'
681
- : 'var(--color-card)',
682
- }
683
- : undefined, className: cn('px-4 align-middle', cellPadY, frozen &&
684
- (selectionEnabled
685
- ? 'sticky left-11 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'
686
- : 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'),
687
- // A rigid track cannot widen for its content, so an
688
- // unbreakable token (a task id, a path, a hash) would
689
- // otherwise run straight across the next column's value.
690
- // Break inside the word instead — the row grows taller,
691
- // which is recoverable; overlapping text is not. Not
692
- // `overflow: hidden`: a cell may host a menu/tooltip that
693
- // has to escape its bounds.
694
- fixedLayout && '[overflow-wrap:anywhere]', cell.column.columnDef.meta
695
- ?.align === 'right' && 'text-right'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
696
- }), hasActions ? (_jsx("td", { className: 'px-2 py-2', children: _jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) }) })) : null] }), isExpanded ? (_jsx("tr", { className: 'border-b border-border bg-muted/20', ref: (el) => {
846
+ return (_jsxs(Fragment, { children: [_jsxs("tr", {
847
+ // 🔴 The virtualizer reads its real height back off this node, and
848
+ // `data-index` is how it knows WHICH row it measured — without it
849
+ // every measurement lands on index 0 and the spacer never grows.
850
+ // (The ref stays OUT of the merge: `getRowProps` cannot take it.)
851
+ ref: virtualEnabled && fixedRowHeight === null ? rowVirtualizer.measureElement : undefined, ...mergeRowProps({
852
+ ...(virtualEnabled ? { 'data-index': rowIndex } : {}),
853
+ style: fixedRowHeight === null ? undefined : { height: fixedRowHeight },
854
+ 'data-expanded': isExpanded || undefined,
855
+ ...dragPropsFor(row.original),
856
+ className: cn(getRowDrag && 'cursor-grab active:cursor-grabbing',
857
+ // Always-on hover wash: wide rows need a trace line for the
858
+ // eye even when the row itself isn't clickable. Zebra fill on
859
+ // every second row gives the eye a guide path across wide
860
+ // columns (design manual §4 zebra-vs-outline rule) — indexed
861
+ // off the DATA row, not DOM position, so an expansion row
862
+ // never flips the rhythm.
863
+ 'border-b border-border transition-colors last:border-0 hover:bg-muted', rowIndex % 2 === 1 && 'bg-muted/50', rowClassName?.(row.original), isExpanded && 'border-0 bg-muted/30', onRowClick &&
864
+ 'cursor-pointer hover:bg-muted focus-visible:bg-muted focus-visible:outline-none'),
865
+ tabIndex: onRowClick ? 0 : undefined,
866
+ onClick: onRowClick ? (event) => onRowClick(row.original, event) : undefined,
867
+ onKeyDown: onRowClick
868
+ ? (e) => {
869
+ if (e.key === 'Enter')
870
+ onRowClick(row.original);
871
+ }
872
+ : undefined,
873
+ }, getRowProps?.(row.original, rowIndex)), children: [(() => {
874
+ const cells = row.getVisibleCells();
875
+ const isLeading = (cell) => cell.column.columnDef.meta?.leading === true;
876
+ const renderCell = (cell, cellIndex) => {
877
+ const frozen = stickyFirstColumn === true && cellIndex === 0;
878
+ return (_jsx("td", { style: frozen
879
+ ? {
880
+ // The zebra step, made OPAQUE — see the prop doc.
881
+ // A custom property rather than `background:`
882
+ // directly, so the value survives any CSS parser
883
+ // that has not learned `color-mix` (happy-dom) and
884
+ // the gate can read it back.
885
+ '--cb-frozen-bg': rowIndex % 2 === 1
886
+ ? 'color-mix(in srgb, var(--color-muted) 50%, var(--color-card))'
887
+ : 'var(--color-card)',
888
+ }
889
+ : undefined, className: cn('px-4 align-middle', cellPadY, frozen &&
890
+ (selectionEnabled
891
+ ? 'sticky left-11 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'
892
+ : 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'),
893
+ // A rigid track cannot widen for its content, so an
894
+ // unbreakable token (a task id, a path, a hash) would
895
+ // otherwise run straight across the next column's value.
896
+ // Break inside the word instead — the row grows taller,
897
+ // which is recoverable; overlapping text is not. Not
898
+ // `overflow: hidden`: a cell may host a menu/tooltip that
899
+ // has to escape its bounds.
900
+ fixedLayout && '[overflow-wrap:anywhere]', cell.column.columnDef.meta?.align === 'right' && 'text-right', cell.column.columnDef.meta?.hideBelow &&
901
+ HIDE_BELOW_CLASS[cell.column.columnDef.meta.hideBelow]), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
902
+ };
903
+ return (_jsxs(_Fragment, { children: [cells.filter(isLeading).map((cell) => renderCell(cell, -1)), selectionEnabled ? (_jsx("td", { ...selectCellProps(row), style: stickyFirstColumn
904
+ ? {
905
+ '--cb-frozen-bg': rowIndex % 2 === 1
906
+ ? 'color-mix(in srgb, var(--color-muted) 50%, var(--color-card))'
907
+ : 'var(--color-card)',
908
+ }
909
+ : undefined, className: cn('w-11 cursor-pointer px-0 text-center align-middle', stickyFirstColumn && 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)]'), children: rowCheckbox(row) })) : null, cells.filter((cell) => !isLeading(cell)).map(renderCell)] }));
910
+ })(), hasActions ? (_jsx("td", { className: 'px-2 py-2', children: _jsx(RowActions, { row: row.original, onEdit: onRowEdit, onDelete: onRowDelete, menu: rowMenu?.(row.original) }) })) : null] }), isExpanded ? (_jsx("tr", { className: 'border-b border-border bg-muted/20', ref: (el) => {
697
911
  if (el && scrolledExpansionRef.current !== row.id) {
698
912
  scrolledExpansionRef.current = row.id;
699
913
  // A row expanded near the fold must reveal its panel.
700
914
  el.scrollIntoView({ block: 'nearest' });
701
915
  }
702
916
  }, children: _jsx("td", { colSpan: visibleColumnCount, className: 'p-0', children: _jsx("div", { className: 'animate-in fade-in slide-in-from-top-1 px-4 pt-1 pb-3 duration-200', children: _jsx("div", { className: 'rounded-lg border border-border bg-card p-3 shadow-raised', children: renderExpanded(row.original) }) }) }) })) : null] }, row.id));
703
- }), padBottom > 0 ? (_jsx("tr", { "aria-hidden": true, style: { height: padBottom }, children: _jsx("td", { colSpan: visibleColumnCount }) })) : null] })] }) })] })), bodyScrolls && (_jsx("div", { "aria-hidden": true, className: 'pointer-events-none sticky bottom-0 -mt-6 h-6 w-full bg-gradient-to-t from-card to-transparent' }))] })), !loading && rows.length > 0 && !paginationHidden ? (_jsx(TablePagination, { table: table, pageSizeOptions: pageSizeOptions })) : null] }));
917
+ }), padBottom > 0 ? (_jsx("tr", { "aria-hidden": true, style: { height: padBottom }, children: _jsx("td", { colSpan: visibleColumnCount }) })) : null] })] }) })] })), bodyScrolls && (_jsx("div", { "aria-hidden": true, className: 'pointer-events-none sticky bottom-0 -mt-6 h-6 w-full bg-gradient-to-t from-card to-transparent' }))] })), !loading && rows.length > 0 && !paginationHidden ? (_jsx(PaginationPart, { table: table, pageSizeOptions: pageSizeOptions, fallback: _jsx(PaginationPlaceholder, {}) })) : null] }));
704
918
  }
705
919
  /**
706
920
  * One row rendered as a phone card: first visible column = the title zone (unlabeled,
@@ -723,6 +937,6 @@ function MobileRowCard({ row, columns, className, onRowClick, dragProps, select,
723
937
  const [titleSideCol, ...restInline] = inline;
724
938
  const body = (_jsxs("div", { className: cn('flex flex-col gap-2', actions && 'pr-10'), children: [title ? (_jsxs("div", { className: 'flex items-start justify-between gap-2 text-sm', children: [_jsx("div", { className: 'min-w-0 flex-1', children: _jsx(CellRenderer, { col: title, value: row.original[title.key], row: row.original }) }), titleSideCol ? (_jsx("span", { className: 'shrink-0', children: _jsx(CellRenderer, { col: titleSideCol, value: row.original[titleSideCol.key], row: row.original }) })) : null] })) : null, restInline.length > 0 && (_jsx("div", { className: 'flex flex-wrap items-center gap-2', children: restInline.map((col) => (_jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }, col.key))) })), detail.length > 0 && (_jsx("dl", { className: 'flex flex-col gap-1.5', children: detail.map((col) => (_jsxs("div", { className: 'flex items-baseline justify-between gap-3', children: [_jsx("dt", { className: 'cb-meta-label shrink-0', children: col.label }), _jsx("dd", { className: 'min-w-0 text-right text-sm', children: _jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }) })] }, col.key))) }))] }));
725
939
  const main = onRowClick ? (_jsx(CardTapTarget, { onClick: () => onRowClick(row.original), className: '-m-1 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', children: body })) : (body);
726
- return (_jsxs(Surface, { level: 'raised', pad: 'sm', radius: 'lg', ...dragProps, className: cn('relative isolate', dragProps?.draggable && 'cursor-grab', className), children: [actions ? _jsx("div", { className: 'absolute top-3 right-3 z-10', children: actions }) : null, select == null ? main : (_jsxs("div", { className: 'flex items-start gap-1', children: [_jsx("div", { className: '-mt-2 -ml-2 shrink-0', children: select }), _jsx("div", { className: 'min-w-0 flex-1', children: main })] })), footer.length > 0 && (_jsx("div", { className: 'mt-3 flex items-center justify-end gap-2 border-t border-border/60 pt-3', children: footer.map((col) => (_jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }, col.key))) })), expanded ? _jsx("div", { className: 'mt-4', children: expanded }) : null] }));
940
+ return (_jsxs(Surface, { level: 'raised', pad: 'sm', radius: 'lg', ...dragProps, className: cn('relative isolate', dragProps?.draggable && 'cursor-grab', className, dragProps?.className), children: [actions ? _jsx("div", { className: 'absolute top-3 right-3 z-10', children: actions }) : null, select == null ? main : (_jsxs("div", { className: 'flex items-start gap-1', children: [_jsx("div", { className: '-mt-2 -ml-2 shrink-0', children: select }), _jsx("div", { className: 'min-w-0 flex-1', children: main })] })), footer.length > 0 && (_jsx("div", { className: 'mt-3 flex items-center justify-end gap-2 border-t border-border/60 pt-3', children: footer.map((col) => (_jsx(CellRenderer, { col: col, value: row.original[col.key], row: row.original }, col.key))) })), expanded ? _jsx("div", { className: 'mt-4', children: expanded }) : null] }));
727
941
  }
728
942
  //# sourceMappingURL=DataTable.js.map