cursedbelt 5.23.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.
- package/dist/react/components/Badge.d.ts +6 -6
- package/dist/react/components/Badge.d.ts.map +1 -1
- package/dist/react/components/Badge.js +13 -5
- package/dist/react/components/Badge.js.map +1 -1
- package/dist/react/components/DataTable/CardView.d.ts +6 -2
- package/dist/react/components/DataTable/CardView.d.ts.map +1 -1
- package/dist/react/components/DataTable/CardView.js +6 -5
- package/dist/react/components/DataTable/CardView.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts +165 -5
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +445 -114
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/DataTable/RowActions.d.ts +3 -1
- package/dist/react/components/DataTable/RowActions.d.ts.map +1 -1
- package/dist/react/components/DataTable/RowActions.js +9 -2
- package/dist/react/components/DataTable/RowActions.js.map +1 -1
- package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
- package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
- package/dist/react/components/DataTable/SortHeader.js +7 -1
- package/dist/react/components/DataTable/SortHeader.js.map +1 -1
- package/dist/react/components/DataTable/cells/CellRenderer.d.ts.map +1 -1
- package/dist/react/components/DataTable/cells/CellRenderer.js +4 -1
- package/dist/react/components/DataTable/cells/CellRenderer.js.map +1 -1
- package/dist/react/components/DataTable/index.d.ts +2 -2
- package/dist/react/components/DataTable/index.d.ts.map +1 -1
- package/dist/react/components/DataTable/index.js +1 -1
- package/dist/react/components/DataTable/index.js.map +1 -1
- package/dist/react/components/DataTable/lazyPart.d.ts +27 -0
- package/dist/react/components/DataTable/lazyPart.d.ts.map +1 -0
- package/dist/react/components/DataTable/lazyPart.js +46 -0
- package/dist/react/components/DataTable/lazyPart.js.map +1 -0
- package/dist/react/components/DataTable/types.d.ts +106 -0
- package/dist/react/components/DataTable/types.d.ts.map +1 -1
- package/dist/react/components/DataTable/types.js.map +1 -1
- package/dist/react/components/auth/SignInCard.d.ts +49 -0
- package/dist/react/components/auth/SignInCard.d.ts.map +1 -0
- package/dist/react/components/auth/SignInCard.js +21 -0
- package/dist/react/components/auth/SignInCard.js.map +1 -0
- package/dist/react/components/auth/index.d.ts +1 -0
- package/dist/react/components/auth/index.d.ts.map +1 -1
- package/dist/react/components/auth/index.js +1 -0
- package/dist/react/components/auth/index.js.map +1 -1
- package/dist/react/deep-link/index.d.ts +1 -1
- package/dist/react/deep-link/index.d.ts.map +1 -1
- package/dist/react/deep-link/index.js.map +1 -1
- package/dist/react/deep-link/useDeepLink.d.ts +21 -15
- package/dist/react/deep-link/useDeepLink.d.ts.map +1 -1
- package/dist/react/deep-link/useDeepLink.js +8 -5
- package/dist/react/deep-link/useDeepLink.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +14 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +2 -2
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +8 -0
- package/dist/react/filter-rail/FilterRail.d.ts +31 -1
- package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
- package/dist/react/filter-rail/FilterRail.js +20 -5
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- package/dist/react/filter-rail/RailSection.d.ts +56 -0
- package/dist/react/filter-rail/RailSection.d.ts.map +1 -0
- package/dist/react/filter-rail/RailSection.js +108 -0
- package/dist/react/filter-rail/RailSection.js.map +1 -0
- package/dist/react/filter-rail/index.d.ts +2 -1
- package/dist/react/filter-rail/index.d.ts.map +1 -1
- package/dist/react/filter-rail/index.js +2 -1
- package/dist/react/filter-rail/index.js.map +1 -1
- package/dist/react/hooks/useScrollMemory.d.ts +6 -0
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +11 -0
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/media-gallery/preferenceBus.d.ts +10 -0
- package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -1
- package/dist/react/media-gallery/preferenceBus.js +24 -0
- package/dist/react/media-gallery/preferenceBus.js.map +1 -1
- package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -1
- package/dist/react/media-gallery/preferenceSync.js +3 -0
- package/dist/react/media-gallery/preferenceSync.js.map +1 -1
- package/dist/react/nav/TopBar.d.ts +8 -1
- package/dist/react/nav/TopBar.d.ts.map +1 -1
- package/dist/react/nav/TopBar.js +2 -2
- package/dist/react/nav/TopBar.js.map +1 -1
- package/dist/styles-areas/analytics.css +18 -15
- package/dist/styles-areas/core.css +8 -7
- package/dist/styles-areas/data-table.css +13 -10
- package/dist/styles-areas/filter-rail.css +6 -5
- package/dist/styles-utilities.css +90 -0
- package/package.json +3 -3
- package/src/react/components/Badge.capsule.spec.ts +178 -0
- package/src/react/components/Badge.tsx +13 -5
- package/src/react/components/DataTable/CardView.tsx +8 -1
- package/src/react/components/DataTable/DataTable.spec.tsx +6 -2
- package/src/react/components/DataTable/DataTable.tsx +770 -86
- package/src/react/components/DataTable/RowActions.tsx +18 -10
- package/src/react/components/DataTable/SortHeader.tsx +19 -0
- package/src/react/components/DataTable/cells/CellRenderer.tsx +2 -0
- package/src/react/components/DataTable/index.ts +10 -1
- package/src/react/components/DataTable/lazyPart.spec.tsx +99 -0
- package/src/react/components/DataTable/lazyPart.tsx +58 -0
- package/src/react/components/DataTable/rowHooks.spec.tsx +289 -0
- package/src/react/components/DataTable/selection.spec.tsx +193 -0
- package/src/react/components/DataTable/tableSystem.spec.tsx +6 -2
- package/src/react/components/DataTable/types.ts +110 -0
- package/src/react/components/DataTable/virtualize.spec.tsx +38 -2
- package/src/react/components/auth/SignInCard.spec.tsx +48 -0
- package/src/react/components/auth/SignInCard.tsx +90 -0
- package/src/react/components/auth/index.ts +1 -0
- package/src/react/deep-link/index.ts +1 -0
- package/src/react/deep-link/useDeepLink.spec.tsx +31 -0
- package/src/react/deep-link/useDeepLink.tsx +31 -11
- package/src/react/file-tree/FileTree.tsx +15 -1
- package/src/react/file-tree/fileTree.css +8 -0
- package/src/react/filter-rail/FilterRail.tsx +93 -26
- package/src/react/filter-rail/RailSection.spec.tsx +156 -0
- package/src/react/filter-rail/RailSection.tsx +216 -0
- package/src/react/filter-rail/index.ts +2 -0
- package/src/react/hooks/useScrollMemory.ts +11 -0
- package/src/react/index.ts +10 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +9 -3
- package/src/react/media-gallery/preferenceBus.ts +28 -0
- package/src/react/media-gallery/preferenceSync.ts +3 -0
- package/src/react/nav/TopBar.spec.tsx +11 -0
- package/src/react/nav/TopBar.tsx +9 -1
- package/src/styles-areas/analytics.css +18 -15
- package/src/styles-areas/core.css +8 -7
- package/src/styles-areas/data-table.css +13 -10
- package/src/styles-areas/filter-rail.css +6 -5
- package/src/styles-utilities.css +90 -0
|
@@ -14,16 +14,27 @@ import {
|
|
|
14
14
|
useReactTable,
|
|
15
15
|
type VisibilityState,
|
|
16
16
|
} from '@tanstack/react-table';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
CSSProperties,
|
|
19
|
+
DragEvent,
|
|
20
|
+
Dispatch,
|
|
21
|
+
KeyboardEvent,
|
|
22
|
+
MouseEvent,
|
|
23
|
+
ReactNode,
|
|
24
|
+
SetStateAction,
|
|
25
|
+
} from 'react';
|
|
18
26
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
19
|
-
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
27
|
+
import { type ComponentType, Fragment, type Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
20
28
|
import { useScrollEdgeFade } from '../../hooks/useScrollEdgeFade.js';
|
|
21
29
|
import { cn } from '../../lib/cn.js';
|
|
30
|
+
import { TAP_TARGET_HEIGHT } from '../../lib/tapTarget.js';
|
|
22
31
|
import { CardTapTarget } from '../CardTapTarget.js';
|
|
32
|
+
import { Checkbox } from '../Checkbox.js';
|
|
23
33
|
import type { DropdownMenuItem } from '../DropdownMenu.js';
|
|
24
34
|
import { EmptyState } from '../EmptyState.js';
|
|
25
35
|
import { Skeleton } from '../Skeleton.js';
|
|
26
36
|
import { Surface } from '../Surface.js';
|
|
37
|
+
import { Tooltip } from '../Tooltip.js';
|
|
27
38
|
import { CardView } from './CardView.js';
|
|
28
39
|
import { CellRenderer } from './cells/CellRenderer.js';
|
|
29
40
|
import {
|
|
@@ -32,13 +43,71 @@ import {
|
|
|
32
43
|
filterableText,
|
|
33
44
|
type FilterQuery,
|
|
34
45
|
} from './filterQuery.js';
|
|
35
|
-
import {
|
|
36
|
-
import { RowActions } from './RowActions.js';
|
|
46
|
+
import { preloadRowMenu, RowActions } from './RowActions.js';
|
|
37
47
|
import { type RowDragPayload, writeRowDrag } from './rowDrag.js';
|
|
38
48
|
import { SortHeader } from './SortHeader.js';
|
|
39
|
-
import {
|
|
40
|
-
|
|
49
|
+
import type {
|
|
50
|
+
ColDef,
|
|
51
|
+
DataTableHandle,
|
|
52
|
+
DataTableRowProps,
|
|
53
|
+
DataTableSelectAll,
|
|
54
|
+
DataTableServerWindow,
|
|
55
|
+
TableViewMode,
|
|
56
|
+
} from './types.js';
|
|
41
57
|
import { resolveColumnAlign } from './types.js';
|
|
58
|
+
import { lazyPart } from './lazyPart.js';
|
|
59
|
+
import type { TablePaginationProps } from './Pagination.js';
|
|
60
|
+
import type { TableToolbarProps } from './Toolbar.js';
|
|
61
|
+
|
|
62
|
+
// 🔴 The toolbar and the pager load only when a table DRAWS them (forge task 2309).
|
|
63
|
+
// Between them they reach Radix's Select, Menu and Popover — ~20 kB gzip that a
|
|
64
|
+
// read-only list (`searchable={false}`, `serverWindow`, no row menu) never opens.
|
|
65
|
+
// Measured on apps/music's track list: 67 kB gzip over its entry ceiling, most of
|
|
66
|
+
// it these two parts' dependencies. A static import made every consumer pay for
|
|
67
|
+
// them. The row kebab's menu is lazy the same way, inside `RowActions`; all three
|
|
68
|
+
// are loaded ahead by `preloadDataTableParts`. `lazyPart` erases the component's
|
|
69
|
+
// type parameter, so each is given back its own signature.
|
|
70
|
+
const ToolbarPart = lazyPart(() =>
|
|
71
|
+
import('./Toolbar.js').then((m) => m.TableToolbar as ComponentType<TableToolbarProps<unknown>>),
|
|
72
|
+
) as unknown as (<TRow>(props: TableToolbarProps<TRow> & { fallback?: ReactNode }) => ReactNode) & {
|
|
73
|
+
preload: () => Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
const PaginationPart = lazyPart(() =>
|
|
76
|
+
import('./Pagination.js').then(
|
|
77
|
+
(m) => m.TablePagination as ComponentType<TablePaginationProps<unknown>>,
|
|
78
|
+
),
|
|
79
|
+
) as unknown as (<TRow>(props: TablePaginationProps<TRow> & { fallback?: ReactNode }) => ReactNode) & {
|
|
80
|
+
preload: () => Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load the table's lazy parts — the toolbar, the pager, the row kebab's menu — now,
|
|
85
|
+
* so every table mounted after this resolves draws them on its first render. For a
|
|
86
|
+
* spec setup that queries them synchronously after `render(…)`, and for an app about
|
|
87
|
+
* to show a table with a toolbar. Never required: without it they arrive a tick late,
|
|
88
|
+
* into a placeholder of their own height.
|
|
89
|
+
*/
|
|
90
|
+
export async function preloadDataTableParts(): Promise<void> {
|
|
91
|
+
await Promise.all([ToolbarPart.preload(), PaginationPart.preload(), preloadRowMenu()]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Where the toolbar will be, the height it will be — so its arrival shifts nothing. */
|
|
95
|
+
function ToolbarPlaceholder() {
|
|
96
|
+
return (
|
|
97
|
+
<div data-cb-table-toolbar-pending='' aria-hidden className='border-b border-border px-2 py-2'>
|
|
98
|
+
<div className={cn('h-9', TAP_TARGET_HEIGHT)} />
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Where the pager will be, the height it will be. */
|
|
104
|
+
function PaginationPlaceholder() {
|
|
105
|
+
return (
|
|
106
|
+
<div data-cb-table-pagination-pending='' aria-hidden className='border-t border-border px-4 py-2.5'>
|
|
107
|
+
<div className={cn('h-8', TAP_TARGET_HEIGHT)} />
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
42
111
|
|
|
43
112
|
// TanStack's header is a render fn (we build a sortable button), so its column has no
|
|
44
113
|
// string header for the ViewOptionsMenu to label a toggle with. Carry the label on
|
|
@@ -51,6 +120,8 @@ declare module '@tanstack/react-table' {
|
|
|
51
120
|
* own `size` is number-only, so proportional tracks ride meta instead. */
|
|
52
121
|
width?: number | string;
|
|
53
122
|
minWidth?: number;
|
|
123
|
+
hideBelow?: 'sm' | 'md' | 'lg';
|
|
124
|
+
leading?: boolean;
|
|
54
125
|
}
|
|
55
126
|
interface TableMeta<TData extends RowData> {
|
|
56
127
|
/** {@link DataTableControls} — see its doc for why it rides on the table. */
|
|
@@ -124,7 +195,7 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
|
|
|
124
195
|
/** Stable row id (selection/keys). Falls back to the row index. */
|
|
125
196
|
getRowId?: (row: TRow) => string;
|
|
126
197
|
/** Click a row — typically opens the record `Drawer`. */
|
|
127
|
-
onRowClick?: (row: TRow) => void;
|
|
198
|
+
onRowClick?: (row: TRow, event?: MouseEvent<HTMLElement>) => void;
|
|
128
199
|
/** Trailing per-row Edit action (renders the actions column). */
|
|
129
200
|
onRowEdit?: (row: TRow) => void;
|
|
130
201
|
/** Trailing per-row Delete action (renders the actions column). */
|
|
@@ -238,8 +309,59 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
|
|
|
238
309
|
* row reports its real height back, so a wrapping cell, a `renderExpanded` panel and
|
|
239
310
|
* the `compact` density all work without the caller computing anything. Pass a number
|
|
240
311
|
* to seed a better first estimate for an unusual row height.
|
|
312
|
+
*
|
|
313
|
+
* It virtualizes the rows it is GIVEN. A list whose rows live on a SERVER — a spacer
|
|
314
|
+
* sized from the server's count, pages loading as they scroll into view, a scrollbar
|
|
315
|
+
* that can be dragged to a row nobody has fetched — is {@link serverWindow}, which
|
|
316
|
+
* implies this.
|
|
241
317
|
*/
|
|
242
318
|
virtualize?: boolean | { estimateRowHeight?: number; overscan?: number };
|
|
319
|
+
/**
|
|
320
|
+
* SERVER-WINDOWED rows: `data` is only a WINDOW of a result set the server holds —
|
|
321
|
+
* rows `[start, start + data.length)` of `total`. Implies {@link virtualize}.
|
|
322
|
+
*
|
|
323
|
+
* ── Why (forge task 2300) ─────────────────────────────────────────────────
|
|
324
|
+
* apps/music's library is 5,409 tracks and its list is the surface the owner "spends
|
|
325
|
+
* all their time in". It pages from the server, and four of its owner-reported bugs
|
|
326
|
+
* were each a way of getting that arithmetic wrong — so the arithmetic lives here once:
|
|
327
|
+
*
|
|
328
|
+
* - The spacer is sized from `total`, so the scrollbar describes the whole result set
|
|
329
|
+
* while only a window is in memory. Rows outside the window render as placeholder
|
|
330
|
+
* rows (`data-cb-placeholder`) of the estimated height.
|
|
331
|
+
* - `onReachEnd` / `onReachStart` fire as the viewport nears either edge of the LOADED
|
|
332
|
+
* window (never of the spacer — measured against `scrollHeight`, a 1,808-row folder
|
|
333
|
+
* stopped paging at row 100 because the trigger sat 1,700 rows past the last real
|
|
334
|
+
* row). `onReachStart` only for a window that does not begin at row 0.
|
|
335
|
+
* - `onNeedWindow(firstVisibleRow)` fires when the viewport comes to REST wholly
|
|
336
|
+
* outside the window — the scrollbar was dragged, or a flick outran paging. Paging
|
|
337
|
+
* cannot catch up from there; the caller fetches ONE window around the row instead.
|
|
338
|
+
* 🔴 Only at rest (`settleMs`): a smooth scroll to a revealed row passes over
|
|
339
|
+
* hundreds of unloaded rows on its way, and acting on each would fetch a window
|
|
340
|
+
* around wherever the animation happened to be.
|
|
341
|
+
*
|
|
342
|
+
* Pair it with {@link handleRef}'s `scrollToIndex` to reveal or follow a row by its
|
|
343
|
+
* row number over the whole result set. Pagination, client-side sort and client-side filtering are off:
|
|
344
|
+
* the server orders and filters, and a window cannot be re-sorted locally.
|
|
345
|
+
*/
|
|
346
|
+
serverWindow?: DataTableServerWindow;
|
|
347
|
+
/**
|
|
348
|
+
* The table's imperative handle — `scrollToIndex(absoluteRow, { align, behavior })`
|
|
349
|
+
* and the `scrollElement` itself (for a scroll-memory hook or a caller telling its
|
|
350
|
+
* own scrolls from the reader's). Only meaningful with {@link virtualize} or
|
|
351
|
+
* {@link serverWindow}, where the table owns the scroll container.
|
|
352
|
+
*/
|
|
353
|
+
handleRef?: Ref<DataTableHandle>;
|
|
354
|
+
/**
|
|
355
|
+
* Classes for the box that SCROLLS — under {@link scrollBody}, {@link virtualize} or
|
|
356
|
+
* {@link serverWindow}, the table's own scroll container; otherwise the same wrapper,
|
|
357
|
+
* which then merely holds the rows. `className` is the OUTER box (toolbar, table and pager).
|
|
358
|
+
*
|
|
359
|
+
* 🔴 Why (forge task 2302): an app's styles and its browser suites address "the list that
|
|
360
|
+
* scrolls" — apps/music measures `.track-list`'s `scrollHeight` against its spacer in eleven
|
|
361
|
+
* places, because that chain is where its worst owner report lived. With the scroller inside
|
|
362
|
+
* the table, only the table can put a name on it.
|
|
363
|
+
*/
|
|
364
|
+
scrollClassName?: string;
|
|
243
365
|
/**
|
|
244
366
|
* Adaptive narrow layouts (container-query based, not viewport — a table inside
|
|
245
367
|
* a narrow split pane or a tablet's sidebar-flanked content area adapts too):
|
|
@@ -269,6 +391,28 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
|
|
|
269
391
|
/** Initial sort applied before any user interaction (e.g. `[{ id: 'updatedAt', desc: true }]`).
|
|
270
392
|
* Only seeds the initial state — click-to-sort still fully controls `sorting` afterward. */
|
|
271
393
|
defaultSort?: SortingState;
|
|
394
|
+
/**
|
|
395
|
+
* The sort, CONTROLLED. A header click reports the next stack through `onSortingChange` and
|
|
396
|
+
* the table shows whatever comes back here. Omit it and the table owns the sort (seeded by
|
|
397
|
+
* `defaultSort`), exactly as before.
|
|
398
|
+
*
|
|
399
|
+
* For a list the SERVER orders (`serverSide`, `serverWindow`), whose sort lives in the app's
|
|
400
|
+
* query and URL: the header then says what the rows are actually sorted by, never what the
|
|
401
|
+
* table last guessed.
|
|
402
|
+
*/
|
|
403
|
+
sorting?: SortingState;
|
|
404
|
+
/**
|
|
405
|
+
* `false`: a click on the only-remaining sort key flips it back to its first direction
|
|
406
|
+
* instead of removing it — for a list that always HAS an order (a server `ORDER BY`), where
|
|
407
|
+
* "unsorted" is not a state the rows can be in. Default `true` (asc → desc → out).
|
|
408
|
+
*/
|
|
409
|
+
enableSortingRemoval?: boolean;
|
|
410
|
+
/**
|
|
411
|
+
* Sorting is unavailable right now, and this is why — the sortable headers stay VISIBLE but
|
|
412
|
+
* inert, with this sentence on hover (the owner-UI rule: disabled with the reason, never
|
|
413
|
+
* hidden). apps/music: *"The list is in play order — turn shuffle off to sort it."*
|
|
414
|
+
*/
|
|
415
|
+
sortDisabledReason?: string;
|
|
272
416
|
/**
|
|
273
417
|
* Wide-grid row density (design manual, grid-layout/ — "high-density data tables
|
|
274
418
|
* over empty visual widgets"):
|
|
@@ -374,6 +518,72 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
|
|
|
374
518
|
/** Identifies this table in the drag payload, so a target can refuse rows
|
|
375
519
|
* from a table it does not serve. */
|
|
376
520
|
dragSourceId?: string;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* ROW SELECTION, controlled. Passing `selectedIds` is what turns it on: a
|
|
524
|
+
* leading checkbox column in the grid, a box per row in the tablet list, the
|
|
525
|
+
* phone cards and the card view, and a tri-state select-all in the header (a
|
|
526
|
+
* "Select all" strip on the tiers that have no header row). Omit it and the
|
|
527
|
+
* table renders exactly as it did before the feature existed.
|
|
528
|
+
*
|
|
529
|
+
* Ids are the table's row ids — `getRowId`, which you should pass: without it
|
|
530
|
+
* the id is the row's index in `data`, which moves when `data` does.
|
|
531
|
+
*
|
|
532
|
+
* 🔴 Select-all covers the FILTERED rows — every row the search box, the
|
|
533
|
+
* per-column filters and the filter builder leave standing — across ALL pages,
|
|
534
|
+
* not only the page on screen. That is Gmail's "select all N matching" without
|
|
535
|
+
* the second click: the header states what the reader can see the table is
|
|
536
|
+
* showing ("these 38 matches"), and a pager is a viewport over that set, not a
|
|
537
|
+
* boundary of it. Rows hidden by the filter are never added or removed by it,
|
|
538
|
+
* so narrowing the search to pick a few more never drops earlier picks.
|
|
539
|
+
* Under `serverSide` the table only knows the rows it was given, so that is
|
|
540
|
+
* the set.
|
|
541
|
+
*/
|
|
542
|
+
selectedIds?: ReadonlySet<string>;
|
|
543
|
+
/** The whole next selection (a new Set) after a row or select-all toggle. */
|
|
544
|
+
onSelectionChange?: (next: ReadonlySet<string>) => void;
|
|
545
|
+
/**
|
|
546
|
+
* The header's select-all box, owned by the CALLER — its state, its name, what a press does.
|
|
547
|
+
*
|
|
548
|
+
* 🔴 For a table that holds only part of what "all" means. Under `serverWindow` the table
|
|
549
|
+
* has a window of a hundred rows out of thousands, so its own select-all could only ever tick
|
|
550
|
+
* the hundred — a lie about the list on screen. apps/music's "all" is every track the VIEW
|
|
551
|
+
* matches, fetched as ids from the server; only the caller can know that set, so the caller
|
|
552
|
+
* owns the box. The row boxes are unchanged (`selectedIds` / `onSelectionChange`).
|
|
553
|
+
*/
|
|
554
|
+
selectAll?: DataTableSelectAll;
|
|
555
|
+
/**
|
|
556
|
+
* Attributes and handlers for one body ROW, merged onto it after the table's own —
|
|
557
|
+
* `className` joins, `style` spreads, and a handler the table also sets runs AFTER the
|
|
558
|
+
* table's (a caller can still `preventDefault()`/`stopPropagation()` for everything later).
|
|
559
|
+
*
|
|
560
|
+
* 🔴 Why (forge task 2302): a row is more than its cells. apps/music's track row carries its
|
|
561
|
+
* identity (`data-track-id` — a windowed row's POSITION is not an identity, and every browser
|
|
562
|
+
* suite addresses rows by it), `aria-current` for the playing track, double-click and Enter to
|
|
563
|
+
* play, its own drag payload for the rail, and drop handling to reorder a playlist. None of
|
|
564
|
+
* that is a cell. `rowIndex` is the row's number over the whole result set (a `serverWindow`
|
|
565
|
+
* row's absolute position).
|
|
566
|
+
*
|
|
567
|
+
* Applied to the grid row, the tablet list row and the phone card.
|
|
568
|
+
*/
|
|
569
|
+
getRowProps?: (row: TRow, rowIndex: number) => DataTableRowProps;
|
|
570
|
+
/** `false` renders that row's checkbox DISABLED (still visible) and keeps the
|
|
571
|
+
* row out of select-all. Pair with {@link unselectableReason}. */
|
|
572
|
+
isRowSelectable?: (row: TRow) => boolean;
|
|
573
|
+
/** Why a row cannot be selected — shown on hover/tap of its disabled box
|
|
574
|
+
* (default "This row can't be selected"). A disabled control that does not
|
|
575
|
+
* say why is the thing the owner UI rule forbids. */
|
|
576
|
+
unselectableReason?: (row: TRow) => string;
|
|
577
|
+
/** The per-row checkbox's accessible name (default: `Select <first column's
|
|
578
|
+
* text>`). */
|
|
579
|
+
selectionLabel?: (row: TRow) => string;
|
|
580
|
+
/**
|
|
581
|
+
* A strip under the toolbar for the selection's summary and its bulk actions —
|
|
582
|
+
* `(rows) => rows.length ? <>{rows.length} games · 38 MB <Button …/></> : null`.
|
|
583
|
+
* Receives every selected row present in `data` (filtered out or not), in
|
|
584
|
+
* `data` order. Return `null` and no strip renders.
|
|
585
|
+
*/
|
|
586
|
+
selectionSummary?: (selectedRows: TRow[]) => ReactNode;
|
|
377
587
|
}
|
|
378
588
|
|
|
379
589
|
/** Build an OnChangeFn that updates local state AND notifies an optional listener. */
|
|
@@ -415,6 +625,8 @@ function chainUpdaterResettingPage<T>(
|
|
|
415
625
|
export const AUTO_TRACK_FLOOR_PX = 120;
|
|
416
626
|
/** The trailing row-actions column is a fixed `w-20`. */
|
|
417
627
|
const ACTIONS_TRACK_PX = 80;
|
|
628
|
+
/** The leading row-selection column is a fixed `w-11` (44px — a full tap target). */
|
|
629
|
+
const SELECT_TRACK_PX = 44;
|
|
418
630
|
|
|
419
631
|
/**
|
|
420
632
|
* The narrowest width at which a `fixedLayout` table still honors the tracks its
|
|
@@ -492,15 +704,23 @@ export function nextSortStack(
|
|
|
492
704
|
current: SortingState,
|
|
493
705
|
id: string,
|
|
494
706
|
multi: boolean,
|
|
707
|
+
options: {
|
|
708
|
+
/** The column's FIRST direction is descending (`ColDef.sortDescFirst`). */
|
|
709
|
+
descFirst?: boolean;
|
|
710
|
+
/** `false`: the last click of the cycle flips back instead of removing the key
|
|
711
|
+
* (`enableSortingRemoval`). Default `true`. */
|
|
712
|
+
removable?: boolean;
|
|
713
|
+
} = {},
|
|
495
714
|
): SortingState {
|
|
715
|
+
const first = options.descFirst ?? false;
|
|
496
716
|
const existing = current.find((sort) => sort.id === id);
|
|
497
717
|
if (!existing) {
|
|
498
|
-
const
|
|
499
|
-
return multi ? [
|
|
718
|
+
const started = { id, desc: first };
|
|
719
|
+
return multi ? [started, ...current] : [started];
|
|
500
720
|
}
|
|
501
|
-
if (
|
|
502
|
-
const flipped =
|
|
503
|
-
return multi ?
|
|
721
|
+
if (existing.desc === first || options.removable === false) {
|
|
722
|
+
const flipped = { id, desc: !existing.desc };
|
|
723
|
+
return multi ? current.map((sort) => (sort.id === id ? flipped : sort)) : [flipped];
|
|
504
724
|
}
|
|
505
725
|
return multi ? current.filter((sort) => sort.id !== id) : [];
|
|
506
726
|
}
|
|
@@ -519,6 +739,49 @@ function readPath(row: unknown, key: string): unknown {
|
|
|
519
739
|
}, row);
|
|
520
740
|
}
|
|
521
741
|
|
|
742
|
+
/**
|
|
743
|
+
* `ColDef.hideBelow` as the container-query classes that do it. Literal strings, so the style
|
|
744
|
+
* generators (`scripts/generate*Styles.ts`) find them in the source.
|
|
745
|
+
*
|
|
746
|
+
* 🔴 The column COLLAPSES — zero width, no padding, its content removed — rather than
|
|
747
|
+
* `display: none`. A hidden cell is not a column, but the rows that SPAN the table (the
|
|
748
|
+
* virtualizer's spacers, a server window's placeholder rows, an expanded panel) still say
|
|
749
|
+
* `colSpan={every column}`, and a span wider than the table's real columns makes the missing
|
|
750
|
+
* ones up: they took the width the hidden columns gave back, so the table's rows stopped short
|
|
751
|
+
* of its right edge and the title — the one width-less column — was crushed to "S…". Measured
|
|
752
|
+
* on apps/music at 390px and 1100px, forge task 2302. Collapsed, the column count never moves,
|
|
753
|
+
* and every span stays true. (`[&>*]:hidden` takes the content out of the accessibility tree
|
|
754
|
+
* too, so a collapsed cell reads as nothing, not as a value nobody can see.)
|
|
755
|
+
*/
|
|
756
|
+
const HIDE_BELOW_CLASS = {
|
|
757
|
+
sm: '@max-2xl:w-0! @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:border-0 @max-2xl:[&>*]:hidden',
|
|
758
|
+
md: '@max-4xl:w-0! @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:border-0 @max-4xl:[&>*]:hidden',
|
|
759
|
+
lg: '@max-6xl:w-0! @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:border-0 @max-6xl:[&>*]:hidden',
|
|
760
|
+
} as const;
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The table's own props for a row, with the caller's `getRowProps` merged on top: classes join,
|
|
764
|
+
* styles spread, and a handler both set runs the table's first, then the caller's.
|
|
765
|
+
*/
|
|
766
|
+
export function mergeRowProps<T extends Record<string, unknown>>(
|
|
767
|
+
own: T,
|
|
768
|
+
extra: DataTableRowProps | undefined,
|
|
769
|
+
): T & DataTableRowProps {
|
|
770
|
+
if (!extra) return own as T & DataTableRowProps;
|
|
771
|
+
const merged: Record<string, unknown> = { ...own, ...extra };
|
|
772
|
+
if (own.className || extra.className) merged.className = cn(own.className as string | undefined, extra.className);
|
|
773
|
+
if (own.style || extra.style) merged.style = { ...(own.style as CSSProperties | undefined), ...extra.style };
|
|
774
|
+
for (const [key, handler] of Object.entries(extra)) {
|
|
775
|
+
const mine = own[key];
|
|
776
|
+
if (!key.startsWith('on') || typeof handler !== 'function' || typeof mine !== 'function') continue;
|
|
777
|
+
merged[key] = (event: unknown) => {
|
|
778
|
+
(mine as (e: unknown) => void)(event);
|
|
779
|
+
(handler as (e: unknown) => void)(event);
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
return merged as T & DataTableRowProps;
|
|
783
|
+
}
|
|
784
|
+
|
|
522
785
|
/** True when a cell value carries nothing worth a column: null/undefined/''/[]. */
|
|
523
786
|
function isEmptyCellValue(v: unknown): boolean {
|
|
524
787
|
if (v == null) return true;
|
|
@@ -566,6 +829,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
566
829
|
minTableWidth,
|
|
567
830
|
scrollBody,
|
|
568
831
|
virtualize,
|
|
832
|
+
serverWindow,
|
|
833
|
+
handleRef,
|
|
834
|
+
scrollClassName,
|
|
569
835
|
mobileCards = true,
|
|
570
836
|
serverSide,
|
|
571
837
|
pageCount,
|
|
@@ -575,6 +841,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
575
841
|
onGlobalFilterChange,
|
|
576
842
|
onPaginationChange,
|
|
577
843
|
defaultSort,
|
|
844
|
+
sorting: sortingProp,
|
|
845
|
+
enableSortingRemoval = true,
|
|
846
|
+
sortDisabledReason,
|
|
578
847
|
density = 'comfortable',
|
|
579
848
|
viewMode,
|
|
580
849
|
onViewModeChange,
|
|
@@ -586,11 +855,23 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
586
855
|
onFilterQueryChange,
|
|
587
856
|
getRowDrag,
|
|
588
857
|
dragSourceId,
|
|
858
|
+
selectedIds,
|
|
859
|
+
onSelectionChange,
|
|
860
|
+
isRowSelectable,
|
|
861
|
+
unselectableReason,
|
|
862
|
+
selectionLabel,
|
|
863
|
+
selectionSummary,
|
|
864
|
+
selectAll,
|
|
865
|
+
getRowProps,
|
|
589
866
|
}: DataTableProps<TRow>) {
|
|
590
867
|
// Vertical cell padding for the wide grid tier — the one lever the density knob
|
|
591
868
|
// pulls (headers + body cells stay aligned by sharing it).
|
|
592
869
|
const cellPadY = density === 'compact' ? 'py-1.5' : 'py-2.5';
|
|
593
|
-
const [
|
|
870
|
+
const [ownSorting, setOwnSorting] = useState<SortingState>(defaultSort ?? []);
|
|
871
|
+
const sorting = sortingProp ?? ownSorting;
|
|
872
|
+
/** The sort as of THIS render, for a write that has to start from it (see `applySorting`). */
|
|
873
|
+
const sortingRef = useRef(sorting);
|
|
874
|
+
sortingRef.current = sorting;
|
|
594
875
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
595
876
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
596
877
|
|
|
@@ -669,18 +950,26 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
669
950
|
*/
|
|
670
951
|
const applySorting = useCallback(
|
|
671
952
|
(next: (prev: SortingState) => SortingState) => {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
});
|
|
953
|
+
// Computed OUTSIDE a state updater: under a controlled `sorting` there is no state to
|
|
954
|
+
// update, and a notify inside an updater fires twice in StrictMode.
|
|
955
|
+
const value = next(sortingRef.current);
|
|
956
|
+
sortingRef.current = value;
|
|
957
|
+
if (sortingProp === undefined) setOwnSorting(value);
|
|
958
|
+
setPagination((p) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }));
|
|
959
|
+
onSortingChange?.(value);
|
|
678
960
|
},
|
|
679
|
-
[onSortingChange],
|
|
961
|
+
[onSortingChange, sortingProp],
|
|
962
|
+
);
|
|
963
|
+
const sortDescFirstOf = useCallback(
|
|
964
|
+
(id: string) => columns.find((col) => col.key === id)?.sortDescFirst ?? false,
|
|
965
|
+
[columns],
|
|
680
966
|
);
|
|
681
967
|
const toggleSortKey = useCallback(
|
|
682
|
-
(id: string) =>
|
|
683
|
-
|
|
968
|
+
(id: string) =>
|
|
969
|
+
applySorting((prev) =>
|
|
970
|
+
nextSortStack(prev, id, multiSort, { descFirst: sortDescFirstOf(id), removable: enableSortingRemoval }),
|
|
971
|
+
),
|
|
972
|
+
[applySorting, enableSortingRemoval, multiSort, sortDescFirstOf],
|
|
684
973
|
);
|
|
685
974
|
|
|
686
975
|
const tsColumns = useMemo<ColumnDef<TRow>[]>(
|
|
@@ -695,6 +984,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
695
984
|
align: resolveColumnAlign(col),
|
|
696
985
|
width: col.width,
|
|
697
986
|
minWidth: col.minWidth,
|
|
987
|
+
hideBelow: col.hideBelow,
|
|
988
|
+
leading: col.leading,
|
|
698
989
|
},
|
|
699
990
|
size: typeof col.width === 'number' ? col.width : undefined,
|
|
700
991
|
enableSorting: col.sortable ?? false,
|
|
@@ -723,6 +1014,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
723
1014
|
sortable={col.sortable}
|
|
724
1015
|
sortIndex={stack.length > 1 ? stack.findIndex((s) => s.id === col.key) : -1}
|
|
725
1016
|
onSort={() => toggleSortKey(col.key)}
|
|
1017
|
+
disabledReason={sortDisabledReason}
|
|
726
1018
|
/>
|
|
727
1019
|
);
|
|
728
1020
|
},
|
|
@@ -735,7 +1027,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
735
1027
|
/>
|
|
736
1028
|
),
|
|
737
1029
|
})),
|
|
738
|
-
[liveColumns, onRowEdit, toggleSortKey],
|
|
1030
|
+
[liveColumns, onRowEdit, sortDisabledReason, toggleSortKey],
|
|
739
1031
|
);
|
|
740
1032
|
|
|
741
1033
|
const labelOf = useCallback(
|
|
@@ -797,17 +1089,37 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
797
1089
|
* failure the pager's absence hides rather than reveals, which is why it is tested by
|
|
798
1090
|
* measuring `scrollHeight` in a real browser.
|
|
799
1091
|
*/
|
|
800
|
-
const virtualOptions = virtualize === true ? {} : virtualize || null;
|
|
1092
|
+
const virtualOptions = virtualize === true || (serverWindow && !virtualize) ? {} : virtualize || null;
|
|
801
1093
|
const virtualEnabled = virtualOptions !== null;
|
|
802
1094
|
const bodyScrolls = scrollBody || virtualEnabled;
|
|
803
1095
|
const paginationHidden = hidePagination || virtualEnabled;
|
|
804
1096
|
|
|
1097
|
+
const selectionEnabled = selectedIds !== undefined;
|
|
1098
|
+
/**
|
|
1099
|
+
* Without `getRowId`, TanStack ids a row by its index in the array it was
|
|
1100
|
+
* handed — which is `filteredData`, so the builder narrowing the rows would
|
|
1101
|
+
* re-number them and a selection would silently move to other rows. When
|
|
1102
|
+
* selection is on, the fallback id is the row's index in `data` instead.
|
|
1103
|
+
* Off, TanStack's own default stands, untouched.
|
|
1104
|
+
*/
|
|
1105
|
+
const dataIndex = useMemo(
|
|
1106
|
+
() => (selectionEnabled && !getRowId ? new Map(data.map((row, i) => [row, i])) : null),
|
|
1107
|
+
[data, getRowId, selectionEnabled],
|
|
1108
|
+
);
|
|
1109
|
+
const resolvedGetRowId = useMemo(
|
|
1110
|
+
() => getRowId ?? (dataIndex ? (row: TRow) => String(dataIndex.get(row)) : undefined),
|
|
1111
|
+
[dataIndex, getRowId],
|
|
1112
|
+
);
|
|
1113
|
+
|
|
805
1114
|
const table = useReactTable({
|
|
806
1115
|
data: filteredData,
|
|
807
1116
|
columns: tsColumns,
|
|
808
|
-
getRowId,
|
|
1117
|
+
getRowId: resolvedGetRowId,
|
|
809
1118
|
state: { sorting, columnFilters, globalFilter: search, columnVisibility, pagination },
|
|
810
|
-
|
|
1119
|
+
// Through `applySorting`, like every other write — so a controlled `sorting` is honoured
|
|
1120
|
+
// whether TanStack or a header asked for the change.
|
|
1121
|
+
onSortingChange: (updater) =>
|
|
1122
|
+
applySorting((prev) => (typeof updater === 'function' ? updater(prev) : updater)),
|
|
811
1123
|
onColumnFiltersChange: chainUpdaterResettingPage(setColumnFilters, setPagination, onColumnFiltersChange),
|
|
812
1124
|
// TanStack hands the updater whatever `state.globalFilter` is, which is now
|
|
813
1125
|
// a TableSearchState; the toolbar sets TEXT, so normalise both shapes.
|
|
@@ -834,14 +1146,16 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
834
1146
|
if (scope && !scope.includes(columnId)) return false;
|
|
835
1147
|
return filterableText(row.getValue(columnId)).toLowerCase().includes(text.toLowerCase());
|
|
836
1148
|
},
|
|
837
|
-
|
|
838
|
-
|
|
1149
|
+
// A server window is server-ordered and server-filtered by construction: a WINDOW
|
|
1150
|
+
// re-sorted locally would put row 4,000's neighbours next to row 12's.
|
|
1151
|
+
manualSorting: serverSide || !!serverWindow,
|
|
1152
|
+
manualFiltering: serverSide || !!serverWindow,
|
|
839
1153
|
manualPagination: serverSide,
|
|
840
1154
|
pageCount: serverSide ? pageCount : undefined,
|
|
841
1155
|
rowCount: serverSide ? rowCount : undefined,
|
|
842
1156
|
getCoreRowModel: getCoreRowModel(),
|
|
843
|
-
getSortedRowModel: serverSide ? undefined : getSortedRowModel(),
|
|
844
|
-
getFilteredRowModel: serverSide ? undefined : getFilteredRowModel(),
|
|
1157
|
+
getSortedRowModel: serverSide || serverWindow ? undefined : getSortedRowModel(),
|
|
1158
|
+
getFilteredRowModel: serverSide || serverWindow ? undefined : getFilteredRowModel(),
|
|
845
1159
|
// 🔴 `virtualEnabled` too, not only `serverSide`. See the note where the flag is
|
|
846
1160
|
// computed: a paginated virtualizer sizes its spacer from one PAGE.
|
|
847
1161
|
getPaginationRowModel: serverSide || virtualEnabled ? undefined : getPaginationRowModel(),
|
|
@@ -883,18 +1197,90 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
883
1197
|
|
|
884
1198
|
const rows = table.getRowModel().rows;
|
|
885
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* 🔴 A {@link serverWindow} row is FIXED-height, never measured. A measured list places
|
|
1202
|
+
* every unloaded row at the estimate and every loaded one at its real height, so the page
|
|
1203
|
+
* that arrives above the viewport re-sizes a hundred rows and moves everything under the
|
|
1204
|
+
* reader — measured in the gate: a smooth reveal of row 4,000 that paged in the rows above
|
|
1205
|
+
* it on arrival came to rest thirteen rows short. apps/music's own list held every row at
|
|
1206
|
+
* one height for the same reason ("Fixed row height is what makes the arithmetic jitter-free").
|
|
1207
|
+
*/
|
|
1208
|
+
const fixedRowHeight = serverWindow
|
|
1209
|
+
? (serverWindow.rowHeight ?? virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44))
|
|
1210
|
+
: null;
|
|
886
1211
|
const estimateRowHeight =
|
|
887
|
-
virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44);
|
|
1212
|
+
fixedRowHeight ?? virtualOptions?.estimateRowHeight ?? (density === 'compact' ? 36 : 44);
|
|
888
1213
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
1214
|
+
/** Rows before the loaded window, and the size of the whole result set — both 0/`rows.length`
|
|
1215
|
+
* without a {@link serverWindow}, which makes every index below the same number as before. */
|
|
1216
|
+
const windowStart = serverWindow ? Math.max(0, serverWindow.start) : 0;
|
|
1217
|
+
const windowTotal = serverWindow ? Math.max(serverWindow.total, windowStart + rows.length) : rows.length;
|
|
1218
|
+
/** The caller's window callbacks, read at FIRE time: by the time a settle timer fires the
|
|
1219
|
+
* window may have moved, and the fresh handler is the one that knows. */
|
|
1220
|
+
const windowRef = useRef(serverWindow);
|
|
1221
|
+
windowRef.current = serverWindow;
|
|
1222
|
+
const loadedRef = useRef({ start: windowStart, end: windowStart + rows.length, total: windowTotal });
|
|
1223
|
+
loadedRef.current = { start: windowStart, end: windowStart + rows.length, total: windowTotal };
|
|
1224
|
+
const settleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1225
|
+
/** The `scrollTop` the last `onChange` saw. The virtualizer also calls `onChange` when its
|
|
1226
|
+
* is-scrolling flag resets and on every re-render; only a MOVE may arm the settle timer, or
|
|
1227
|
+
* the flag's own reset re-asks for the window that was just requested (measured: twice). */
|
|
1228
|
+
const lastTopRef = useRef<number | null>(null);
|
|
1229
|
+
useEffect(
|
|
1230
|
+
() => () => {
|
|
1231
|
+
if (settleRef.current) clearTimeout(settleRef.current);
|
|
1232
|
+
},
|
|
1233
|
+
[],
|
|
1234
|
+
);
|
|
889
1235
|
const rowVirtualizer = useVirtualizer({
|
|
890
|
-
count: virtualEnabled ?
|
|
1236
|
+
count: virtualEnabled ? windowTotal : 0,
|
|
891
1237
|
getScrollElement: () => scrollRef.current,
|
|
892
1238
|
estimateSize: () => estimateRowHeight,
|
|
893
1239
|
overscan: virtualOptions?.overscan ?? 8,
|
|
1240
|
+
onChange: serverWindow
|
|
1241
|
+
? (instance) => {
|
|
1242
|
+
const win = windowRef.current;
|
|
1243
|
+
const element = instance.scrollElement;
|
|
1244
|
+
if (!win || !element) return;
|
|
1245
|
+
const { start, end, total } = loadedRef.current;
|
|
1246
|
+
// Rows over the whole result set: the first one on screen, and the one after the last.
|
|
1247
|
+
const items = instance.getVirtualItems();
|
|
1248
|
+
if (items.length === 0) return;
|
|
1249
|
+
const top = element.scrollTop;
|
|
1250
|
+
const bottom = top + element.clientHeight;
|
|
1251
|
+
const firstShown = items.find((item) => item.end > top)?.index ?? items[0]!.index;
|
|
1252
|
+
const lastShown = (items.findLast((item) => item.start < bottom)?.index ?? items.at(-1)!.index) + 1;
|
|
1253
|
+
const moved = lastTopRef.current !== top;
|
|
1254
|
+
lastTopRef.current = top;
|
|
1255
|
+
// Nothing on screen is loaded. Hand the destination over once the scroll RESTS,
|
|
1256
|
+
// and do not also ask for a page — it would load rows nowhere near the reader.
|
|
1257
|
+
if (firstShown >= end || lastShown <= start) {
|
|
1258
|
+
if (!moved) return;
|
|
1259
|
+
if (settleRef.current) clearTimeout(settleRef.current);
|
|
1260
|
+
settleRef.current = setTimeout(() => {
|
|
1261
|
+
settleRef.current = null;
|
|
1262
|
+
// Read at FIRE time: the window may have arrived while the scroll rested.
|
|
1263
|
+
const loaded = loadedRef.current;
|
|
1264
|
+
if (firstShown < loaded.end && lastShown > loaded.start) return;
|
|
1265
|
+
windowRef.current?.onNeedWindow?.(firstShown);
|
|
1266
|
+
}, win.settleMs ?? 120);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (settleRef.current) {
|
|
1270
|
+
clearTimeout(settleRef.current);
|
|
1271
|
+
settleRef.current = null;
|
|
1272
|
+
}
|
|
1273
|
+
// Against the end of the LOADED window, never the spacer's — see the prop doc.
|
|
1274
|
+
if (win.onReachEnd && end < total && end - lastShown < (win.reachRows ?? 20)) win.onReachEnd();
|
|
1275
|
+
// Tighter, and only off row 0: a reveal lands a few rows into a fresh window, and a
|
|
1276
|
+
// generous trigger here would fire on arrival and cascade back to the top.
|
|
1277
|
+
if (win.onReachStart && start > 0 && firstShown - start < (win.reachRowsUp ?? 8)) win.onReachStart();
|
|
1278
|
+
}
|
|
1279
|
+
: undefined,
|
|
894
1280
|
// 🔴 MEASURED, not assumed. A wrapping cell, an expanded detail panel and the
|
|
895
1281
|
// `compact` density all change a row's height, and a caller cannot be asked to
|
|
896
1282
|
// compute any of them. `estimateSize` only seeds the spacer before first paint.
|
|
897
|
-
measureElement: (el) => el.getBoundingClientRect().height,
|
|
1283
|
+
measureElement: (el) => fixedRowHeight ?? el.getBoundingClientRect().height,
|
|
898
1284
|
});
|
|
899
1285
|
/**
|
|
900
1286
|
* 🔴 Invalidate the measurement cache when the row height changes.
|
|
@@ -918,13 +1304,153 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
918
1304
|
}, [rowVirtualizer, estimateRowHeight]);
|
|
919
1305
|
const virtualRows = virtualEnabled ? rowVirtualizer.getVirtualItems() : [];
|
|
920
1306
|
/** The rows to render, and the two spacers that keep the scrollbar honest about the
|
|
921
|
-
* ones that are not. Off, this is every row and no spacers — byte-identical output.
|
|
922
|
-
|
|
1307
|
+
* ones that are not. Off, this is every row and no spacers — byte-identical output.
|
|
1308
|
+
* `null` is a row a {@link serverWindow} has not loaded: a placeholder of the estimated
|
|
1309
|
+
* height, so the window can land anywhere in the spacer before its rows arrive. */
|
|
1310
|
+
const bodyRows: (Row<TRow> | null)[] = virtualEnabled
|
|
1311
|
+
? virtualRows.map((v) => rows[v.index - windowStart] ?? null)
|
|
1312
|
+
: rows;
|
|
1313
|
+
useImperativeHandle(
|
|
1314
|
+
handleRef,
|
|
1315
|
+
() => ({
|
|
1316
|
+
get scrollElement() {
|
|
1317
|
+
return scrollRef.current;
|
|
1318
|
+
},
|
|
1319
|
+
scrollToIndex(index, options = {}) {
|
|
1320
|
+
const element = scrollRef.current;
|
|
1321
|
+
if (!element || !virtualEnabled) return null;
|
|
1322
|
+
const clamped = Math.max(0, Math.min(index, loadedRef.current.total - 1));
|
|
1323
|
+
const [offset] = rowVirtualizer.getOffsetForIndex(clamped, options.align ?? 'center') ?? [clamped * estimateRowHeight];
|
|
1324
|
+
const top = Math.max(0, Math.round(offset));
|
|
1325
|
+
element.scrollTo({ top, behavior: options.behavior ?? 'auto' });
|
|
1326
|
+
return top;
|
|
1327
|
+
},
|
|
1328
|
+
}),
|
|
1329
|
+
[estimateRowHeight, rowVirtualizer, virtualEnabled],
|
|
1330
|
+
);
|
|
923
1331
|
const padTop = virtualEnabled ? (virtualRows[0]?.start ?? 0) : 0;
|
|
924
1332
|
const padBottom = virtualEnabled
|
|
925
1333
|
? rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0)
|
|
926
1334
|
: 0;
|
|
927
1335
|
const hasActions = !!(onRowEdit || onRowDelete || rowMenu);
|
|
1336
|
+
|
|
1337
|
+
// ── Row selection (see the `selectedIds` prop doc) ──────────────────────────
|
|
1338
|
+
const canSelect = (row: TRow) => isRowSelectable?.(row) ?? true;
|
|
1339
|
+
// Select-all's universe: the FILTERED rows across every page, minus the ones
|
|
1340
|
+
// that cannot be selected. `getFilteredRowModel` is pre-pagination by design.
|
|
1341
|
+
const selectableFilteredIds = selectionEnabled
|
|
1342
|
+
? table
|
|
1343
|
+
.getFilteredRowModel()
|
|
1344
|
+
.rows.filter((row) => canSelect(row.original))
|
|
1345
|
+
.map((row) => row.id)
|
|
1346
|
+
: [];
|
|
1347
|
+
const selectedFilteredCount = selectionEnabled
|
|
1348
|
+
? selectableFilteredIds.filter((id) => selectedIds.has(id)).length
|
|
1349
|
+
: 0;
|
|
1350
|
+
const headerChecked: boolean | 'indeterminate' =
|
|
1351
|
+
selectedFilteredCount === 0
|
|
1352
|
+
? false
|
|
1353
|
+
: selectedFilteredCount === selectableFilteredIds.length
|
|
1354
|
+
? true
|
|
1355
|
+
: 'indeterminate';
|
|
1356
|
+
const toggleRowSelected = (id: string) => {
|
|
1357
|
+
if (!selectedIds) return;
|
|
1358
|
+
const next = new Set(selectedIds);
|
|
1359
|
+
if (next.has(id)) next.delete(id);
|
|
1360
|
+
else next.add(id);
|
|
1361
|
+
onSelectionChange?.(next);
|
|
1362
|
+
};
|
|
1363
|
+
const toggleAllSelected = () => {
|
|
1364
|
+
if (!selectedIds) return;
|
|
1365
|
+
const next = new Set(selectedIds);
|
|
1366
|
+
if (headerChecked === true) for (const id of selectableFilteredIds) next.delete(id);
|
|
1367
|
+
else for (const id of selectableFilteredIds) next.add(id);
|
|
1368
|
+
onSelectionChange?.(next);
|
|
1369
|
+
};
|
|
1370
|
+
const labelForSelection = (row: TRow): string => {
|
|
1371
|
+
if (selectionLabel) return selectionLabel(row);
|
|
1372
|
+
const first = liveColumns[0];
|
|
1373
|
+
const value = first ? readPath(row, first.key) : undefined;
|
|
1374
|
+
const text = first?.displayValue ? first.displayValue(value, row) : filterableText(value);
|
|
1375
|
+
return `Select ${text || 'row'}`;
|
|
1376
|
+
};
|
|
1377
|
+
const selectAllLabel = `Select all ${selectableFilteredIds.length} rows`;
|
|
1378
|
+
/** One row's checkbox — the same control in every tier. */
|
|
1379
|
+
const rowCheckbox = (row: Row<TRow>): ReactNode => {
|
|
1380
|
+
if (!selectedIds) return null;
|
|
1381
|
+
const selectable = canSelect(row.original);
|
|
1382
|
+
const box = (
|
|
1383
|
+
<Checkbox
|
|
1384
|
+
checked={selectedIds.has(row.id)}
|
|
1385
|
+
disabled={!selectable}
|
|
1386
|
+
onCheckedChange={() => toggleRowSelected(row.id)}
|
|
1387
|
+
aria-label={labelForSelection(row.original)}
|
|
1388
|
+
/>
|
|
1389
|
+
);
|
|
1390
|
+
if (selectable) return box;
|
|
1391
|
+
// A disabled button fires no pointer events, so the reason hangs off a
|
|
1392
|
+
// wrapper — visible and explained, never hidden (the owner UI rule).
|
|
1393
|
+
return (
|
|
1394
|
+
<Tooltip content={unselectableReason?.(row.original) ?? "This row can't be selected"} mobile='tap'>
|
|
1395
|
+
<span data-cb-unselectable='' className='inline-flex'>
|
|
1396
|
+
{box}
|
|
1397
|
+
</span>
|
|
1398
|
+
</Tooltip>
|
|
1399
|
+
);
|
|
1400
|
+
};
|
|
1401
|
+
/**
|
|
1402
|
+
* The whole cell toggles, not just the 16px box — generous hit target. A click
|
|
1403
|
+
* that landed on the checkbox's own `<label>` has already toggled it, so only
|
|
1404
|
+
* the padding around it is handled here. Propagation stops either way so a
|
|
1405
|
+
* selection click is never also the row click.
|
|
1406
|
+
*/
|
|
1407
|
+
const selectCellProps = (row: Row<TRow>) => ({
|
|
1408
|
+
'data-cb-select-cell': '',
|
|
1409
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
1410
|
+
event.stopPropagation();
|
|
1411
|
+
if ((event.target as Element).closest('label')) return;
|
|
1412
|
+
if (canSelect(row.original)) toggleRowSelected(row.id);
|
|
1413
|
+
},
|
|
1414
|
+
// Space/Enter on the focused checkbox must not bubble into the row's
|
|
1415
|
+
// Enter-to-open handler.
|
|
1416
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => event.stopPropagation(),
|
|
1417
|
+
});
|
|
1418
|
+
const headerCheckbox = (label?: ReactNode) =>
|
|
1419
|
+
selectAll ? (
|
|
1420
|
+
// The caller's box (see `selectAll`). 🔴 The Tooltip wraps a SPAN, never the Checkbox:
|
|
1421
|
+
// a Radix trigger clones its child with its own `data-state`, which would overwrite the
|
|
1422
|
+
// one the box paints its tick and its `Minus` from (apps/music measured it, 2026-09-19).
|
|
1423
|
+
<Tooltip content={selectAll.label}>
|
|
1424
|
+
<span className='inline-flex'>
|
|
1425
|
+
<Checkbox
|
|
1426
|
+
checked={selectAll.checked}
|
|
1427
|
+
disabled={selectAll.disabled}
|
|
1428
|
+
onCheckedChange={(checked) => selectAll.onChange(checked === true)}
|
|
1429
|
+
aria-label={selectAll.label}
|
|
1430
|
+
label={label}
|
|
1431
|
+
labelClassName={label == null ? undefined : 'text-xs text-muted-foreground'}
|
|
1432
|
+
/>
|
|
1433
|
+
</span>
|
|
1434
|
+
</Tooltip>
|
|
1435
|
+
) : (
|
|
1436
|
+
<Checkbox
|
|
1437
|
+
checked={headerChecked}
|
|
1438
|
+
disabled={selectableFilteredIds.length === 0}
|
|
1439
|
+
onCheckedChange={toggleAllSelected}
|
|
1440
|
+
aria-label={selectAllLabel}
|
|
1441
|
+
label={label}
|
|
1442
|
+
labelClassName={label == null ? undefined : 'text-xs text-muted-foreground'}
|
|
1443
|
+
/>
|
|
1444
|
+
);
|
|
1445
|
+
const summaryNode =
|
|
1446
|
+
selectedIds && selectionSummary
|
|
1447
|
+
? selectionSummary(
|
|
1448
|
+
resolvedGetRowId
|
|
1449
|
+
? data.filter((row) => selectedIds.has(resolvedGetRowId(row)))
|
|
1450
|
+
: [],
|
|
1451
|
+
)
|
|
1452
|
+
: null;
|
|
1453
|
+
|
|
928
1454
|
const toolbarNode =
|
|
929
1455
|
typeof toolbar === 'function'
|
|
930
1456
|
? toolbar(table)
|
|
@@ -936,8 +1462,15 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
936
1462
|
// passes its own gets exactly that, and `TableToolbar` reads the same
|
|
937
1463
|
// controls off `table.options.meta`, so composing a toolbar is never
|
|
938
1464
|
// the thing that costs you the search box.
|
|
939
|
-
(searchable ?
|
|
940
|
-
|
|
1465
|
+
(searchable ? (
|
|
1466
|
+
<ToolbarPart
|
|
1467
|
+
table={table}
|
|
1468
|
+
searchPlaceholder={searchPlaceholder}
|
|
1469
|
+
fallback={<ToolbarPlaceholder />}
|
|
1470
|
+
/>
|
|
1471
|
+
) : null));
|
|
1472
|
+
const visibleColumnCount =
|
|
1473
|
+
table.getVisibleLeafColumns().length + (hasActions ? 1 : 0) + (selectionEnabled ? 1 : 0);
|
|
941
1474
|
// Cards track the SAME live visibility state as the grid (ViewOptionsMenu toggles
|
|
942
1475
|
// apply to both) — not just the ColDefs' initial `hidden` flags.
|
|
943
1476
|
const cardColumns = liveColumns.filter((c) => table.getColumn(c.key)?.getIsVisible() ?? true);
|
|
@@ -950,7 +1483,10 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
950
1483
|
// columns missing rather than one that scrolls (`useScrollEdgeFade`).
|
|
951
1484
|
const gridFade = useScrollEdgeFade();
|
|
952
1485
|
const tableMinWidth = fixedLayout
|
|
953
|
-
? Math.max(
|
|
1486
|
+
? Math.max(
|
|
1487
|
+
minTableWidth ?? 0,
|
|
1488
|
+
declaredTrackFloor(cardColumns, hasActions) + (selectionEnabled ? SELECT_TRACK_PX : 0),
|
|
1489
|
+
)
|
|
954
1490
|
: minTableWidth;
|
|
955
1491
|
|
|
956
1492
|
return (
|
|
@@ -959,6 +1495,18 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
959
1495
|
<div className={cn('@container flex min-h-0 flex-col', className)}>
|
|
960
1496
|
{toolbarNode}
|
|
961
1497
|
|
|
1498
|
+
{summaryNode != null ? (
|
|
1499
|
+
// The selection's own line of chrome: the app's "12 games · 38 MB" and
|
|
1500
|
+
// its bulk actions. `aria-live` so the count is announced as it changes.
|
|
1501
|
+
<div
|
|
1502
|
+
data-cb-table-selection-summary=''
|
|
1503
|
+
aria-live='polite'
|
|
1504
|
+
className='flex min-h-11 flex-wrap items-center gap-3 border-b border-border px-4 py-2 text-sm'
|
|
1505
|
+
>
|
|
1506
|
+
{summaryNode}
|
|
1507
|
+
</div>
|
|
1508
|
+
) : null}
|
|
1509
|
+
|
|
962
1510
|
{loading ? (
|
|
963
1511
|
<div className='flex flex-col gap-2 p-4'>
|
|
964
1512
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
@@ -966,19 +1514,36 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
966
1514
|
<Skeleton key={`dt-skeleton-${i}`} className='h-10 w-full' />
|
|
967
1515
|
))}
|
|
968
1516
|
</div>
|
|
969
|
-
) : rows.length === 0 ? (
|
|
1517
|
+
) : rows.length === 0 && windowTotal === 0 ? (
|
|
970
1518
|
<div className='p-2'>{emptyState ?? <EmptyState title='No results' />}</div>
|
|
971
1519
|
) : (
|
|
972
1520
|
// scrollBody makes the table body the one scroll container for BOTH axes —
|
|
973
1521
|
// required for stickyHeader to pin (see the prop doc); ScrollArea's
|
|
974
1522
|
// vertical-only flex model can't own horizontal table scroll.
|
|
975
|
-
//
|
|
976
|
-
|
|
1523
|
+
// `data-cb-table-scroll` names the box when it IS the scroller — the table's own, the way
|
|
1524
|
+
// `ScrollArea` stamps `data-cb-scrollarea`, so a layout check can tell the design system's
|
|
1525
|
+
// scroller from a hand-rolled `overflow: auto` wearing the same class (forge task 2302).
|
|
1526
|
+
<div
|
|
1527
|
+
ref={scrollRef}
|
|
1528
|
+
data-cb-table-scroll={bodyScrolls ? '' : undefined}
|
|
1529
|
+
// guardrails-ignore no-raw-overflow: dual-axis table scroll container — sticky header pins to it; ScrollArea cannot own horizontal table scroll.
|
|
1530
|
+
className={cn(bodyScrolls && 'min-h-0 flex-1 overflow-auto', scrollClassName)}
|
|
1531
|
+
>
|
|
977
1532
|
{/* ── The CARD view (opt-in via `viewMode`) — the same rows the grid
|
|
978
1533
|
would show, after the same filter/sort/page, through the same cell
|
|
979
1534
|
renderers. It replaces all three width tiers rather than sitting
|
|
980
1535
|
beside them: a card grid already reflows, so a second breakpoint
|
|
981
1536
|
ladder inside it would only fight the container query. ── */}
|
|
1537
|
+
{/* Select-all for the tiers with no header row to carry it: the phone
|
|
1538
|
+
cards, and the card view at every width. */}
|
|
1539
|
+
{selectionEnabled && (viewMode === 'card' || mobileCards) ? (
|
|
1540
|
+
<div
|
|
1541
|
+
data-cb-table-select-all=''
|
|
1542
|
+
className={cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden')}
|
|
1543
|
+
>
|
|
1544
|
+
{headerCheckbox('Select all')}
|
|
1545
|
+
</div>
|
|
1546
|
+
) : null}
|
|
982
1547
|
{viewMode === 'card' ? (
|
|
983
1548
|
<CardView
|
|
984
1549
|
columns={cardColumns}
|
|
@@ -986,6 +1551,14 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
986
1551
|
getRowId={getRowId}
|
|
987
1552
|
onRowClick={onRowClick}
|
|
988
1553
|
dragPropsFor={getRowDrag ? dragPropsFor : undefined}
|
|
1554
|
+
selectFor={
|
|
1555
|
+
selectionEnabled
|
|
1556
|
+
? (_row, index) => {
|
|
1557
|
+
const tsRow = rows[index];
|
|
1558
|
+
return tsRow ? rowCheckbox(tsRow) : null;
|
|
1559
|
+
}
|
|
1560
|
+
: undefined
|
|
1561
|
+
}
|
|
989
1562
|
cardMinWidth={cardMinWidth}
|
|
990
1563
|
className='p-2'
|
|
991
1564
|
/>
|
|
@@ -1001,7 +1574,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1001
1574
|
columns={cardColumns}
|
|
1002
1575
|
className={rowClassName?.(row.original)}
|
|
1003
1576
|
onRowClick={onRowClick}
|
|
1004
|
-
dragProps={dragPropsFor(row.original)}
|
|
1577
|
+
dragProps={mergeRowProps(dragPropsFor(row.original), getRowProps?.(row.original, windowStart + row.index))}
|
|
1578
|
+
select={selectionEnabled ? rowCheckbox(row) : undefined}
|
|
1005
1579
|
actions={
|
|
1006
1580
|
hasActions ? (
|
|
1007
1581
|
<RowActions
|
|
@@ -1047,6 +1621,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1047
1621
|
// absent (the header) and every preceding lane drifts ~32px off
|
|
1048
1622
|
// its label — the v4 judge's tablet misalignment finding.
|
|
1049
1623
|
const template = [
|
|
1624
|
+
...(selectionEnabled ? ['44px'] : []),
|
|
1050
1625
|
'minmax(0,1fr)',
|
|
1051
1626
|
...rest.map(trackOf),
|
|
1052
1627
|
...(hasActions ? ['32px'] : []),
|
|
@@ -1061,6 +1636,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1061
1636
|
className='grid items-center gap-3 border-b border-border px-4 py-2 text-caption font-bold uppercase tracking-wider text-muted-foreground'
|
|
1062
1637
|
style={{ gridTemplateColumns: template }}
|
|
1063
1638
|
>
|
|
1639
|
+
{selectionEnabled ? <span className='flex justify-center'>{headerCheckbox()}</span> : null}
|
|
1064
1640
|
<span>{title?.label ?? ''}</span>
|
|
1065
1641
|
{rest.map((col) => (
|
|
1066
1642
|
<span key={col.key} className={cn(resolveColumnAlign(col) === 'right' && 'text-right')}>
|
|
@@ -1076,14 +1652,24 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1076
1652
|
return (
|
|
1077
1653
|
<Fragment key={row.id}>
|
|
1078
1654
|
<div
|
|
1079
|
-
{...
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1655
|
+
{...mergeRowProps(
|
|
1656
|
+
{
|
|
1657
|
+
...dragPropsFor(row.original),
|
|
1658
|
+
className: cn(
|
|
1659
|
+
'grid items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40',
|
|
1660
|
+
getRowDrag && 'cursor-grab active:cursor-grabbing',
|
|
1661
|
+
rowClassName?.(row.original),
|
|
1662
|
+
),
|
|
1663
|
+
style: { gridTemplateColumns: template },
|
|
1664
|
+
},
|
|
1665
|
+
getRowProps?.(row.original, windowStart + row.index),
|
|
1084
1666
|
)}
|
|
1085
|
-
style={{ gridTemplateColumns: template }}
|
|
1086
1667
|
>
|
|
1668
|
+
{selectionEnabled ? (
|
|
1669
|
+
<div {...selectCellProps(row)} className='-my-3 flex self-stretch items-center justify-center'>
|
|
1670
|
+
{rowCheckbox(row)}
|
|
1671
|
+
</div>
|
|
1672
|
+
) : null}
|
|
1087
1673
|
<div className='min-w-0'>
|
|
1088
1674
|
{title ? (
|
|
1089
1675
|
<CellRenderer
|
|
@@ -1154,7 +1740,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1154
1740
|
<thead>
|
|
1155
1741
|
{table.getHeaderGroups().map((hg) => (
|
|
1156
1742
|
<tr key={hg.id}>
|
|
1157
|
-
{
|
|
1743
|
+
{(() => {
|
|
1744
|
+
// `ColDef.leading` columns sit BEFORE the checkbox column; see its doc.
|
|
1745
|
+
const renderHeader = (h: (typeof hg.headers)[number], headerIndex: number) => {
|
|
1158
1746
|
const sorted = h.column.getIsSorted();
|
|
1159
1747
|
const meta = h.column.columnDef.meta;
|
|
1160
1748
|
const width = meta?.width ?? h.column.columnDef.size;
|
|
@@ -1193,8 +1781,11 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1193
1781
|
// next header slides over the frozen one. It is opaque
|
|
1194
1782
|
// for the same reason the body cells are.
|
|
1195
1783
|
frozen &&
|
|
1196
|
-
|
|
1784
|
+
(selectionEnabled
|
|
1785
|
+
? 'sticky left-11 bg-card shadow-[1px_0_0_0_var(--color-border)]'
|
|
1786
|
+
: 'sticky left-0 bg-card shadow-[1px_0_0_0_var(--color-border)]'),
|
|
1197
1787
|
frozen && (stickyHeader ? 'z-30' : 'z-20'),
|
|
1788
|
+
meta?.hideBelow && HIDE_BELOW_CLASS[meta.hideBelow],
|
|
1198
1789
|
)}
|
|
1199
1790
|
>
|
|
1200
1791
|
{h.isPlaceholder
|
|
@@ -1202,7 +1793,28 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1202
1793
|
: flexRender(h.column.columnDef.header, h.getContext())}
|
|
1203
1794
|
</th>
|
|
1204
1795
|
);
|
|
1205
|
-
}
|
|
1796
|
+
};
|
|
1797
|
+
const isLeading = (h: (typeof hg.headers)[number]) => h.column.columnDef.meta?.leading === true;
|
|
1798
|
+
return (
|
|
1799
|
+
<>
|
|
1800
|
+
{hg.headers.filter(isLeading).map((h) => renderHeader(h, -1))}
|
|
1801
|
+
{selectionEnabled ? (
|
|
1802
|
+
<th
|
|
1803
|
+
scope='col'
|
|
1804
|
+
className={cn(
|
|
1805
|
+
'w-11 border-b border-border px-0 text-center',
|
|
1806
|
+
stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
|
|
1807
|
+
stickyFirstColumn && 'sticky left-0 bg-card',
|
|
1808
|
+
stickyFirstColumn && (stickyHeader ? 'z-30' : 'z-20'),
|
|
1809
|
+
)}
|
|
1810
|
+
>
|
|
1811
|
+
{headerCheckbox()}
|
|
1812
|
+
</th>
|
|
1813
|
+
) : null}
|
|
1814
|
+
{hg.headers.filter((h) => !isLeading(h)).map(renderHeader)}
|
|
1815
|
+
</>
|
|
1816
|
+
);
|
|
1817
|
+
})()}
|
|
1206
1818
|
{hasActions ? (
|
|
1207
1819
|
<th
|
|
1208
1820
|
className={cn(
|
|
@@ -1226,6 +1838,25 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1226
1838
|
</tr>
|
|
1227
1839
|
) : null}
|
|
1228
1840
|
{bodyRows.map((row, sliceIndex) => {
|
|
1841
|
+
if (row === null) {
|
|
1842
|
+
// A row the server window has not loaded yet: the estimated height, so
|
|
1843
|
+
// the spacer arithmetic holds, and a skeleton line rather than a gap.
|
|
1844
|
+
const index = virtualRows[sliceIndex]!.index;
|
|
1845
|
+
return (
|
|
1846
|
+
<tr
|
|
1847
|
+
key={`cb-placeholder-${index}`}
|
|
1848
|
+
data-index={index}
|
|
1849
|
+
data-cb-placeholder=''
|
|
1850
|
+
aria-hidden
|
|
1851
|
+
className={cn('border-b border-border', index % 2 === 1 && 'bg-muted/50')}
|
|
1852
|
+
style={{ height: estimateRowHeight }}
|
|
1853
|
+
>
|
|
1854
|
+
<td colSpan={visibleColumnCount} className='px-3'>
|
|
1855
|
+
<Skeleton className='h-3' style={{ width: '40%' }} />
|
|
1856
|
+
</td>
|
|
1857
|
+
</tr>
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1229
1860
|
/* 🔴 The ZEBRA index is the row's ABSOLUTE position, not its
|
|
1230
1861
|
position in the rendered slice. Off by that, the stripes
|
|
1231
1862
|
would repaint themselves every time the window moved — the
|
|
@@ -1240,12 +1871,14 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1240
1871
|
// 🔴 The virtualizer reads its real height back off this node, and
|
|
1241
1872
|
// `data-index` is how it knows WHICH row it measured — without it
|
|
1242
1873
|
// every measurement lands on index 0 and the spacer never grows.
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
data-
|
|
1247
|
-
{
|
|
1248
|
-
|
|
1874
|
+
// (The ref stays OUT of the merge: `getRowProps` cannot take it.)
|
|
1875
|
+
ref={virtualEnabled && fixedRowHeight === null ? rowVirtualizer.measureElement : undefined}
|
|
1876
|
+
{...mergeRowProps({
|
|
1877
|
+
...(virtualEnabled ? { 'data-index': rowIndex } : {}),
|
|
1878
|
+
style: fixedRowHeight === null ? undefined : { height: fixedRowHeight },
|
|
1879
|
+
'data-expanded': isExpanded || undefined,
|
|
1880
|
+
...dragPropsFor(row.original),
|
|
1881
|
+
className: cn(
|
|
1249
1882
|
getRowDrag && 'cursor-grab active:cursor-grabbing',
|
|
1250
1883
|
// Always-on hover wash: wide rows need a trace line for the
|
|
1251
1884
|
// eye even when the row itself isn't clickable. Zebra fill on
|
|
@@ -1259,18 +1892,20 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1259
1892
|
isExpanded && 'border-0 bg-muted/30',
|
|
1260
1893
|
onRowClick &&
|
|
1261
1894
|
'cursor-pointer hover:bg-muted focus-visible:bg-muted focus-visible:outline-none',
|
|
1262
|
-
)
|
|
1263
|
-
tabIndex
|
|
1264
|
-
onClick
|
|
1265
|
-
onKeyDown
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
}
|
|
1895
|
+
),
|
|
1896
|
+
tabIndex: onRowClick ? 0 : undefined,
|
|
1897
|
+
onClick: onRowClick ? (event: MouseEvent<HTMLElement>) => onRowClick(row.original, event) : undefined,
|
|
1898
|
+
onKeyDown: onRowClick
|
|
1899
|
+
? (e: KeyboardEvent<HTMLElement>) => {
|
|
1900
|
+
if (e.key === 'Enter') onRowClick(row.original);
|
|
1901
|
+
}
|
|
1902
|
+
: undefined,
|
|
1903
|
+
}, getRowProps?.(row.original, rowIndex))}
|
|
1272
1904
|
>
|
|
1273
|
-
{
|
|
1905
|
+
{(() => {
|
|
1906
|
+
const cells = row.getVisibleCells();
|
|
1907
|
+
const isLeading = (cell: (typeof cells)[number]) => cell.column.columnDef.meta?.leading === true;
|
|
1908
|
+
const renderCell = (cell: (typeof cells)[number], cellIndex: number) => {
|
|
1274
1909
|
const frozen = stickyFirstColumn === true && cellIndex === 0;
|
|
1275
1910
|
return (
|
|
1276
1911
|
<td
|
|
@@ -1294,7 +1929,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1294
1929
|
'px-4 align-middle',
|
|
1295
1930
|
cellPadY,
|
|
1296
1931
|
frozen &&
|
|
1297
|
-
|
|
1932
|
+
(selectionEnabled
|
|
1933
|
+
? 'sticky left-11 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'
|
|
1934
|
+
: 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'),
|
|
1298
1935
|
// A rigid track cannot widen for its content, so an
|
|
1299
1936
|
// unbreakable token (a task id, a path, a hash) would
|
|
1300
1937
|
// otherwise run straight across the next column's value.
|
|
@@ -1303,14 +1940,43 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1303
1940
|
// `overflow: hidden`: a cell may host a menu/tooltip that
|
|
1304
1941
|
// has to escape its bounds.
|
|
1305
1942
|
fixedLayout && '[overflow-wrap:anywhere]',
|
|
1306
|
-
|
|
1307
|
-
|
|
1943
|
+
cell.column.columnDef.meta?.align === 'right' && 'text-right',
|
|
1944
|
+
cell.column.columnDef.meta?.hideBelow &&
|
|
1945
|
+
HIDE_BELOW_CLASS[cell.column.columnDef.meta.hideBelow],
|
|
1308
1946
|
)}
|
|
1309
1947
|
>
|
|
1310
1948
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1311
1949
|
</td>
|
|
1312
1950
|
);
|
|
1313
|
-
}
|
|
1951
|
+
};
|
|
1952
|
+
return (
|
|
1953
|
+
<>
|
|
1954
|
+
{cells.filter(isLeading).map((cell) => renderCell(cell, -1))}
|
|
1955
|
+
{selectionEnabled ? (
|
|
1956
|
+
<td
|
|
1957
|
+
{...selectCellProps(row)}
|
|
1958
|
+
style={
|
|
1959
|
+
stickyFirstColumn
|
|
1960
|
+
? ({
|
|
1961
|
+
'--cb-frozen-bg':
|
|
1962
|
+
rowIndex % 2 === 1
|
|
1963
|
+
? 'color-mix(in srgb, var(--color-muted) 50%, var(--color-card))'
|
|
1964
|
+
: 'var(--color-card)',
|
|
1965
|
+
} as CSSProperties)
|
|
1966
|
+
: undefined
|
|
1967
|
+
}
|
|
1968
|
+
className={cn(
|
|
1969
|
+
'w-11 cursor-pointer px-0 text-center align-middle',
|
|
1970
|
+
stickyFirstColumn && 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)]',
|
|
1971
|
+
)}
|
|
1972
|
+
>
|
|
1973
|
+
{rowCheckbox(row)}
|
|
1974
|
+
</td>
|
|
1975
|
+
) : null}
|
|
1976
|
+
{cells.filter((cell) => !isLeading(cell)).map(renderCell)}
|
|
1977
|
+
</>
|
|
1978
|
+
);
|
|
1979
|
+
})()}
|
|
1314
1980
|
{hasActions ? (
|
|
1315
1981
|
<td className='px-2 py-2'>
|
|
1316
1982
|
<RowActions
|
|
@@ -1371,7 +2037,11 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1371
2037
|
)}
|
|
1372
2038
|
|
|
1373
2039
|
{!loading && rows.length > 0 && !paginationHidden ? (
|
|
1374
|
-
<
|
|
2040
|
+
<PaginationPart
|
|
2041
|
+
table={table}
|
|
2042
|
+
pageSizeOptions={pageSizeOptions}
|
|
2043
|
+
fallback={<PaginationPlaceholder />}
|
|
2044
|
+
/>
|
|
1375
2045
|
) : null}
|
|
1376
2046
|
</div>
|
|
1377
2047
|
);
|
|
@@ -1390,6 +2060,7 @@ function MobileRowCard<TRow extends Record<string, unknown>>({
|
|
|
1390
2060
|
className,
|
|
1391
2061
|
onRowClick,
|
|
1392
2062
|
dragProps,
|
|
2063
|
+
select,
|
|
1393
2064
|
actions,
|
|
1394
2065
|
expanded,
|
|
1395
2066
|
}: {
|
|
@@ -1397,7 +2068,10 @@ function MobileRowCard<TRow extends Record<string, unknown>>({
|
|
|
1397
2068
|
columns: ColDef<TRow>[];
|
|
1398
2069
|
className?: string;
|
|
1399
2070
|
onRowClick?: (row: TRow) => void;
|
|
1400
|
-
|
|
2071
|
+
/** The drag-source props and the caller's `getRowProps`, already merged. */
|
|
2072
|
+
dragProps?: DataTableRowProps;
|
|
2073
|
+
/** The row's selection checkbox, when the table selects — leads the card. */
|
|
2074
|
+
select?: ReactNode;
|
|
1401
2075
|
actions: ReactNode;
|
|
1402
2076
|
expanded: ReactNode;
|
|
1403
2077
|
}) {
|
|
@@ -1457,6 +2131,19 @@ function MobileRowCard<TRow extends Record<string, unknown>>({
|
|
|
1457
2131
|
</div>
|
|
1458
2132
|
);
|
|
1459
2133
|
|
|
2134
|
+
const main = onRowClick ? (
|
|
2135
|
+
// Navigation affordance over the card body only — the actions footer stays
|
|
2136
|
+
// OUTSIDE this region so tapping Stop/Start never also fires the row click.
|
|
2137
|
+
<CardTapTarget
|
|
2138
|
+
onClick={() => onRowClick(row.original)}
|
|
2139
|
+
className='-m-1 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
2140
|
+
>
|
|
2141
|
+
{body}
|
|
2142
|
+
</CardTapTarget>
|
|
2143
|
+
) : (
|
|
2144
|
+
body
|
|
2145
|
+
);
|
|
2146
|
+
|
|
1460
2147
|
return (
|
|
1461
2148
|
// `relative isolate`: the kebab cluster is absolutely positioned inline with the
|
|
1462
2149
|
// title (top-right), and isolate keeps it from painting over the NEXT card
|
|
@@ -1469,7 +2156,7 @@ function MobileRowCard<TRow extends Record<string, unknown>>({
|
|
|
1469
2156
|
pad='sm'
|
|
1470
2157
|
radius='lg'
|
|
1471
2158
|
{...dragProps}
|
|
1472
|
-
className={cn('relative isolate', dragProps?.draggable && 'cursor-grab', className)}
|
|
2159
|
+
className={cn('relative isolate', dragProps?.draggable && 'cursor-grab', className, dragProps?.className)}
|
|
1473
2160
|
>
|
|
1474
2161
|
{/* Context/kebab actions ride the top-right corner inline with the title —
|
|
1475
2162
|
parking them in the footer left a dead whitespace ocean mid-card (owner
|
|
@@ -1478,17 +2165,14 @@ function MobileRowCard<TRow extends Record<string, unknown>>({
|
|
|
1478
2165
|
the card edge (misclick buffer, owner v3 mobile teardown) with a ≥44px
|
|
1479
2166
|
coarse-pointer hit box on the kebab itself (RowActions). */}
|
|
1480
2167
|
{actions ? <div className='absolute top-3 right-3 z-10'>{actions}</div> : null}
|
|
1481
|
-
{
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
className='-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
</CardTapTarget>
|
|
1490
|
-
) : (
|
|
1491
|
-
body
|
|
2168
|
+
{select == null ? main : (
|
|
2169
|
+
// The checkbox leads the card, OUTSIDE the tap target, so ticking a row
|
|
2170
|
+
// never also opens it. `-mt-2 -ml-2` lets the 44px coarse-pointer target
|
|
2171
|
+
// sit in the card's padding rather than pushing the title right.
|
|
2172
|
+
<div className='flex items-start gap-1'>
|
|
2173
|
+
<div className='-mt-2 -ml-2 shrink-0'>{select}</div>
|
|
2174
|
+
<div className='min-w-0 flex-1'>{main}</div>
|
|
2175
|
+
</div>
|
|
1492
2176
|
)}
|
|
1493
2177
|
{/* Primary row actions (an actions column) sit in a bordered footer strip
|
|
1494
2178
|
contained inside the card bounds — the one hairline a card carries (owner
|