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.
Files changed (131) 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/CardView.d.ts +6 -2
  6. package/dist/react/components/DataTable/CardView.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/CardView.js +6 -5
  8. package/dist/react/components/DataTable/CardView.js.map +1 -1
  9. package/dist/react/components/DataTable/DataTable.d.ts +165 -5
  10. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  11. package/dist/react/components/DataTable/DataTable.js +445 -114
  12. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  13. package/dist/react/components/DataTable/RowActions.d.ts +3 -1
  14. package/dist/react/components/DataTable/RowActions.d.ts.map +1 -1
  15. package/dist/react/components/DataTable/RowActions.js +9 -2
  16. package/dist/react/components/DataTable/RowActions.js.map +1 -1
  17. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  18. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  19. package/dist/react/components/DataTable/SortHeader.js +7 -1
  20. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  21. package/dist/react/components/DataTable/cells/CellRenderer.d.ts.map +1 -1
  22. package/dist/react/components/DataTable/cells/CellRenderer.js +4 -1
  23. package/dist/react/components/DataTable/cells/CellRenderer.js.map +1 -1
  24. package/dist/react/components/DataTable/index.d.ts +2 -2
  25. package/dist/react/components/DataTable/index.d.ts.map +1 -1
  26. package/dist/react/components/DataTable/index.js +1 -1
  27. package/dist/react/components/DataTable/index.js.map +1 -1
  28. package/dist/react/components/DataTable/lazyPart.d.ts +27 -0
  29. package/dist/react/components/DataTable/lazyPart.d.ts.map +1 -0
  30. package/dist/react/components/DataTable/lazyPart.js +46 -0
  31. package/dist/react/components/DataTable/lazyPart.js.map +1 -0
  32. package/dist/react/components/DataTable/types.d.ts +106 -0
  33. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  34. package/dist/react/components/DataTable/types.js.map +1 -1
  35. package/dist/react/components/auth/SignInCard.d.ts +49 -0
  36. package/dist/react/components/auth/SignInCard.d.ts.map +1 -0
  37. package/dist/react/components/auth/SignInCard.js +21 -0
  38. package/dist/react/components/auth/SignInCard.js.map +1 -0
  39. package/dist/react/components/auth/index.d.ts +1 -0
  40. package/dist/react/components/auth/index.d.ts.map +1 -1
  41. package/dist/react/components/auth/index.js +1 -0
  42. package/dist/react/components/auth/index.js.map +1 -1
  43. package/dist/react/deep-link/index.d.ts +1 -1
  44. package/dist/react/deep-link/index.d.ts.map +1 -1
  45. package/dist/react/deep-link/index.js.map +1 -1
  46. package/dist/react/deep-link/useDeepLink.d.ts +21 -15
  47. package/dist/react/deep-link/useDeepLink.d.ts.map +1 -1
  48. package/dist/react/deep-link/useDeepLink.js +8 -5
  49. package/dist/react/deep-link/useDeepLink.js.map +1 -1
  50. package/dist/react/file-tree/FileTree.d.ts +14 -1
  51. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  52. package/dist/react/file-tree/FileTree.js +2 -2
  53. package/dist/react/file-tree/FileTree.js.map +1 -1
  54. package/dist/react/file-tree/fileTree.css +8 -0
  55. package/dist/react/filter-rail/FilterRail.d.ts +31 -1
  56. package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
  57. package/dist/react/filter-rail/FilterRail.js +20 -5
  58. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  59. package/dist/react/filter-rail/RailSection.d.ts +56 -0
  60. package/dist/react/filter-rail/RailSection.d.ts.map +1 -0
  61. package/dist/react/filter-rail/RailSection.js +108 -0
  62. package/dist/react/filter-rail/RailSection.js.map +1 -0
  63. package/dist/react/filter-rail/index.d.ts +2 -1
  64. package/dist/react/filter-rail/index.d.ts.map +1 -1
  65. package/dist/react/filter-rail/index.js +2 -1
  66. package/dist/react/filter-rail/index.js.map +1 -1
  67. package/dist/react/hooks/useScrollMemory.d.ts +6 -0
  68. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  69. package/dist/react/hooks/useScrollMemory.js +11 -0
  70. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  71. package/dist/react/index.d.ts +3 -1
  72. package/dist/react/index.d.ts.map +1 -1
  73. package/dist/react/index.js +6 -1
  74. package/dist/react/index.js.map +1 -1
  75. package/dist/react/media-gallery/preferenceBus.d.ts +10 -0
  76. package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -1
  77. package/dist/react/media-gallery/preferenceBus.js +24 -0
  78. package/dist/react/media-gallery/preferenceBus.js.map +1 -1
  79. package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -1
  80. package/dist/react/media-gallery/preferenceSync.js +3 -0
  81. package/dist/react/media-gallery/preferenceSync.js.map +1 -1
  82. package/dist/react/nav/TopBar.d.ts +8 -1
  83. package/dist/react/nav/TopBar.d.ts.map +1 -1
  84. package/dist/react/nav/TopBar.js +2 -2
  85. package/dist/react/nav/TopBar.js.map +1 -1
  86. package/dist/styles-areas/analytics.css +18 -15
  87. package/dist/styles-areas/core.css +8 -7
  88. package/dist/styles-areas/data-table.css +13 -10
  89. package/dist/styles-areas/filter-rail.css +6 -5
  90. package/dist/styles-utilities.css +90 -0
  91. package/package.json +3 -3
  92. package/src/react/components/Badge.capsule.spec.ts +178 -0
  93. package/src/react/components/Badge.tsx +13 -5
  94. package/src/react/components/DataTable/CardView.tsx +8 -1
  95. package/src/react/components/DataTable/DataTable.spec.tsx +6 -2
  96. package/src/react/components/DataTable/DataTable.tsx +770 -86
  97. package/src/react/components/DataTable/RowActions.tsx +18 -10
  98. package/src/react/components/DataTable/SortHeader.tsx +19 -0
  99. package/src/react/components/DataTable/cells/CellRenderer.tsx +2 -0
  100. package/src/react/components/DataTable/index.ts +10 -1
  101. package/src/react/components/DataTable/lazyPart.spec.tsx +99 -0
  102. package/src/react/components/DataTable/lazyPart.tsx +58 -0
  103. package/src/react/components/DataTable/rowHooks.spec.tsx +289 -0
  104. package/src/react/components/DataTable/selection.spec.tsx +193 -0
  105. package/src/react/components/DataTable/tableSystem.spec.tsx +6 -2
  106. package/src/react/components/DataTable/types.ts +110 -0
  107. package/src/react/components/DataTable/virtualize.spec.tsx +38 -2
  108. package/src/react/components/auth/SignInCard.spec.tsx +48 -0
  109. package/src/react/components/auth/SignInCard.tsx +90 -0
  110. package/src/react/components/auth/index.ts +1 -0
  111. package/src/react/deep-link/index.ts +1 -0
  112. package/src/react/deep-link/useDeepLink.spec.tsx +31 -0
  113. package/src/react/deep-link/useDeepLink.tsx +31 -11
  114. package/src/react/file-tree/FileTree.tsx +15 -1
  115. package/src/react/file-tree/fileTree.css +8 -0
  116. package/src/react/filter-rail/FilterRail.tsx +93 -26
  117. package/src/react/filter-rail/RailSection.spec.tsx +156 -0
  118. package/src/react/filter-rail/RailSection.tsx +216 -0
  119. package/src/react/filter-rail/index.ts +2 -0
  120. package/src/react/hooks/useScrollMemory.ts +11 -0
  121. package/src/react/index.ts +10 -0
  122. package/src/react/media-gallery/MediaGallery.spec.tsx +9 -3
  123. package/src/react/media-gallery/preferenceBus.ts +28 -0
  124. package/src/react/media-gallery/preferenceSync.ts +3 -0
  125. package/src/react/nav/TopBar.spec.tsx +11 -0
  126. package/src/react/nav/TopBar.tsx +9 -1
  127. package/src/styles-areas/analytics.css +18 -15
  128. package/src/styles-areas/core.css +8 -7
  129. package/src/styles-areas/data-table.css +13 -10
  130. package/src/styles-areas/filter-rail.css +6 -5
  131. 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 { CSSProperties, DragEvent, Dispatch, ReactNode, SetStateAction } from 'react';
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 { TablePagination } from './Pagination.js';
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 { TableToolbar } from './Toolbar.js';
40
- import type { ColDef, TableViewMode } from './types.js';
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 ascending = { id, desc: false };
499
- return multi ? [ascending, ...current] : [ascending];
718
+ const started = { id, desc: first };
719
+ return multi ? [started, ...current] : [started];
500
720
  }
501
- if (!existing.desc) {
502
- const flipped = current.map((sort) => (sort.id === id ? { id, desc: true } : sort));
503
- return multi ? flipped : [{ id, desc: true }];
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 [sorting, setSorting] = useState<SortingState>(defaultSort ?? []);
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
- setSorting((prev) => {
673
- const value = next(prev);
674
- setPagination((p) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }));
675
- onSortingChange?.(value);
676
- return value;
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) => applySorting((prev) => nextSortStack(prev, id, multiSort)),
683
- [applySorting, multiSort],
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
- onSortingChange: chainUpdaterResettingPage(setSorting, setPagination, onSortingChange),
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
- manualSorting: serverSide,
838
- manualFiltering: serverSide,
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 ? rows.length : 0,
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
- const bodyRows = virtualEnabled ? virtualRows.map((v) => rows[v.index]!) : rows;
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 ? <TableToolbar table={table} searchPlaceholder={searchPlaceholder} /> : null));
940
- const visibleColumnCount = table.getVisibleLeafColumns().length + (hasActions ? 1 : 0);
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(minTableWidth ?? 0, declaredTrackFloor(cardColumns, hasActions))
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
- // guardrails-ignore no-raw-overflow: dual-axis table scroll container — sticky header pins to it; ScrollArea cannot own horizontal table scroll.
976
- <div ref={scrollRef} className={cn(bodyScrolls && 'min-h-0 flex-1 overflow-auto')}>
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
- {...dragPropsFor(row.original)}
1080
- className={cn(
1081
- 'grid items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40',
1082
- getRowDrag && 'cursor-grab active:cursor-grabbing',
1083
- rowClassName?.(row.original),
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
- {hg.headers.map((h, headerIndex) => {
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
- 'sticky left-0 bg-card shadow-[1px_0_0_0_var(--color-border)]',
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
- {...(virtualEnabled
1244
- ? { ref: rowVirtualizer.measureElement, 'data-index': rowIndex }
1245
- : {})}
1246
- data-expanded={isExpanded || undefined}
1247
- {...dragPropsFor(row.original)}
1248
- className={cn(
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={onRowClick ? 0 : undefined}
1264
- onClick={onRowClick ? () => onRowClick(row.original) : undefined}
1265
- onKeyDown={
1266
- onRowClick
1267
- ? (e) => {
1268
- if (e.key === 'Enter') onRowClick(row.original);
1269
- }
1270
- : undefined
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
- {row.getVisibleCells().map((cell, cellIndex) => {
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
- 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]',
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
- (cell.column.columnDef.meta as { align?: 'left' | 'right' } | undefined)
1307
- ?.align === 'right' && 'text-right',
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
- <TablePagination table={table} pageSizeOptions={pageSizeOptions} />
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
- dragProps?: { draggable?: boolean; onDragStart?: (event: DragEvent<HTMLElement>) => void };
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
- {onRowClick ? (
1482
- // Navigation affordance over the card body only — the actions footer stays
1483
- // OUTSIDE this region so tapping Stop/Start never also fires the row click.
1484
- <CardTapTarget
1485
- onClick={() => onRowClick(row.original)}
1486
- className='-m-1 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
1487
- >
1488
- {body}
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