basalt-ui 1.31.0 → 1.32.1

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 (102) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +212 -20
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +240 -59
  5. package/configs/oxlint-plugin.test.ts +329 -1
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/BandStrip.js +18 -66
  8. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  9. package/dist/charts/kinds/DualPanel.js +5 -26
  10. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  11. package/dist/charts/kinds/MirroredBars.js +10 -68
  12. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  13. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  14. package/dist/charts/kinds/band-chrome.js +71 -0
  15. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  16. package/dist/charts/primitives/CartesianChart.js +49 -76
  17. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  18. package/dist/charts/primitives/ChartCard.js +5 -23
  19. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  20. package/dist/charts/primitives/ChartFrame.js +23 -40
  21. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +5 -133
  23. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  24. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartTooltip.js +16 -1
  26. package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
  27. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  28. package/dist/charts/primitives/HoverOverlay.js +17 -1
  29. package/dist/charts/primitives/chart-card-context.d.ts +17 -13
  30. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
  31. package/dist/charts/primitives/chart-card-context.js +10 -5
  32. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  33. package/dist/charts/primitives/chart-frame-layout.js +25 -31
  34. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  35. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  36. package/dist/charts/primitives/legend-disclosure.js +153 -0
  37. package/dist/controls/controls.module.css +5 -0
  38. package/dist/controls/sync-button.module.css +1 -0
  39. package/dist/dashboard/stat-group.module.css +19 -64
  40. package/dist/dashboard/widget-grid.module.css +11 -38
  41. package/dist/data/data-table-state.d.ts +83 -0
  42. package/dist/data/data-table-state.d.ts.map +1 -0
  43. package/dist/data/data-table-state.js +139 -0
  44. package/dist/data/data-table.d.ts +40 -2
  45. package/dist/data/data-table.d.ts.map +1 -1
  46. package/dist/data/data-table.js +179 -277
  47. package/dist/forms/form-layout.d.ts +6 -1
  48. package/dist/forms/form-layout.d.ts.map +1 -1
  49. package/dist/forms/form-layout.module.css +6 -13
  50. package/dist/guard/index.d.ts +2 -2
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +10 -14
  53. package/dist/notifications/center.d.ts.map +1 -1
  54. package/dist/provider/index.d.ts.map +1 -1
  55. package/dist/provider/index.js +12 -10
  56. package/dist/shell/aside-docking.d.ts +31 -0
  57. package/dist/shell/aside-docking.d.ts.map +1 -0
  58. package/dist/shell/aside-docking.js +19 -0
  59. package/dist/shell/index.d.ts +0 -11
  60. package/dist/shell/index.d.ts.map +1 -1
  61. package/dist/shell/index.js +13 -14
  62. package/dist/shell/page-bar.d.ts +24 -0
  63. package/dist/shell/page-bar.d.ts.map +1 -1
  64. package/dist/shell/use-breakpoint.d.ts +1 -1
  65. package/dist/theme/floating.module.css +1 -0
  66. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  67. package/dist/tokens/index.d.ts +2 -0
  68. package/dist/tokens/index.d.ts.map +1 -1
  69. package/dist/tokens/index.js +4 -0
  70. package/llms.txt +2 -2
  71. package/package.json +2 -1
  72. package/src/charts/kinds/BandStrip.tsx +18 -64
  73. package/src/charts/kinds/DualPanel.tsx +4 -24
  74. package/src/charts/kinds/MirroredBars.tsx +12 -66
  75. package/src/charts/kinds/band-chrome.tsx +91 -0
  76. package/src/charts/primitives/CartesianChart.tsx +79 -122
  77. package/src/charts/primitives/ChartCard.tsx +5 -27
  78. package/src/charts/primitives/ChartFrame.tsx +37 -76
  79. package/src/charts/primitives/ChartLegend.tsx +7 -154
  80. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  81. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  82. package/src/charts/primitives/chart-card-context.ts +25 -16
  83. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  84. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  85. package/src/controls/controls.module.css +5 -0
  86. package/src/controls/sync-button.module.css +1 -0
  87. package/src/dashboard/stat-group.module.css +19 -64
  88. package/src/dashboard/widget-grid.module.css +11 -38
  89. package/src/data/data-table-state.ts +218 -0
  90. package/src/data/data-table.tsx +134 -218
  91. package/src/forms/form-layout.module.css +6 -13
  92. package/src/forms/form-layout.tsx +6 -1
  93. package/src/guard/index.ts +38 -34
  94. package/src/notifications/center.tsx +3 -0
  95. package/src/provider/index.tsx +18 -17
  96. package/src/shell/aside-docking.ts +54 -0
  97. package/src/shell/index.tsx +15 -37
  98. package/src/shell/page-bar.tsx +24 -0
  99. package/src/shell/use-breakpoint.ts +2 -2
  100. package/src/theme/floating.module.css +1 -0
  101. package/src/theme-lab/derive-controls.tsx +2 -0
  102. package/src/tokens/index.ts +5 -0
@@ -37,8 +37,10 @@ import {
37
37
  Checkbox,
38
38
  Group,
39
39
  Pagination,
40
+ ScrollArea,
40
41
  Select,
41
42
  Skeleton,
43
+ Stack,
42
44
  Table,
43
45
  Text,
44
46
  TextInput,
@@ -48,7 +50,6 @@ import type {
48
50
  Column,
49
51
  ColumnDef,
50
52
  ColumnFiltersState,
51
- ColumnPinningState,
52
53
  FilterFn,
53
54
  PaginationState,
54
55
  Row,
@@ -56,7 +57,6 @@ import type {
56
57
  RowSelectionState,
57
58
  SortingState,
58
59
  Table as TanstackTable,
59
- Updater,
60
60
  VisibilityState,
61
61
  } from '@tanstack/react-table'
62
62
  import {
@@ -67,14 +67,8 @@ import {
67
67
  getSortedRowModel,
68
68
  useReactTable,
69
69
  } from '@tanstack/react-table'
70
- import type {
71
- CSSProperties,
72
- KeyboardEvent as ReactKeyboardEvent,
73
- MouseEvent as ReactMouseEvent,
74
- ReactNode,
75
- RefObject,
76
- } from 'react'
77
- import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
70
+ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from 'react'
71
+ import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react'
78
72
  import { cx } from '../common/props'
79
73
  import type { BasaltProps, SlotStylesProps } from '../common/props'
80
74
  import { BASALT_PREFIX } from '../common/errors'
@@ -96,6 +90,13 @@ import type { EnumField, FieldHandle, MultiField } from '../state'
96
90
  import { WidgetHeader } from '../dashboard'
97
91
  import { ErrorState } from '../dashboard/query-state'
98
92
  import type { QueryStateLike } from '../dashboard/query-state'
93
+ import {
94
+ activationProps,
95
+ EMPTY_FOLD_SET,
96
+ useCardProjection,
97
+ useDataTableState,
98
+ useRowDisclosure,
99
+ } from './data-table-state'
99
100
  import { dataQueryBranch } from './query-branch'
100
101
  import { isDev } from '../common/is-dev'
101
102
  import classes from './data-table.module.css'
@@ -388,8 +389,9 @@ function BulkActionBar({ count, actions }: { count: number; actions: BarAction[]
388
389
 
389
390
  /**
390
391
  * The four boxes `BasaltDataTable` paints: `root` is the outer fragment's replacement wrapper,
391
- * `toolbar` is the search/facets/actions row, `table` is the Mantine `Table` itself, `footer` is
392
- * the pagination bar.
392
+ * `toolbar` is the search/facets/actions row, `table` is whichever body renders — the Mantine
393
+ * `Table`, or the card list's `Stack` while `renderCard` projects — and `footer` is the pagination
394
+ * bar.
393
395
  */
394
396
  export type BasaltDataTableSlot = 'root' | 'toolbar' | 'table' | 'footer'
395
397
 
@@ -595,6 +597,40 @@ export type BasaltDataTableProps<T> = BasaltProps &
595
597
  * <BasaltDataTable data={rows} columns={columns} onRowActivate={(row) => setSelected(row)} />
596
598
  */
597
599
  onRowActivate?: (row: T) => void
600
+
601
+ /**
602
+ * A compact card projection of one row, rendered INSTEAD of the table while the table's own box
603
+ * is narrower than the `regular` container class (`CONTAINER_CLASSES`, 480px) — keyed on the
604
+ * width this table actually has, never the viewport, so a table in a narrow aside or a split
605
+ * column swaps on a desktop too, and a full-width one does not swap early on a tablet.
606
+ *
607
+ * The cards are the SAME processed rows the table would draw — sorted, filtered and paged — so a
608
+ * list can never disagree with its table; a fixed card order is `initialSorting`, not a second
609
+ * sort. Everything around the body stays: the header (title · count · toolbar), the bulk bar,
610
+ * the pager, and `query` / `isLoading` / `emptyState`. `onRowActivate` makes each card
611
+ * activatable exactly as it does a row (click, or Enter on the focused card — never Space, the
612
+ * browser's own page scroll on a focused non-button). The selection
613
+ * checkbox is not projected — a card list with a selection keeps its bulk bar but no ticks.
614
+ *
615
+ * An activatable card takes `tabIndex=0` + `data-activatable`, never `role="button"`: a card is
616
+ * where a row's actions and links land (argo's all do), and a `button` role makes every
617
+ * descendant presentational, so those nested controls would vanish from the accessibility tree.
618
+ * The affordance is the cursor and the focus ring, as on the row. `maxHeight` caps the card list
619
+ * in a `ScrollArea`; `minWidth` does not apply — it is a horizontal floor for columns, and a
620
+ * card list has none. `stickyHeader` has nothing to stick while the cards render. A card order
621
+ * with no user sort is `data`'s own order — sort it there (or `initialSorting`) for a domain
622
+ * order.
623
+ *
624
+ * Reach for it when the row carries more than the columns a phone can hold (a status + label +
625
+ * age header, a clamped title, a prose line). A table that only needs to shed a column or two
626
+ * already has that: the column fold hides the lowest-priority columns behind a per-row
627
+ * disclosure with no prop at all. Server render and first paint are the table.
628
+ *
629
+ * @example
630
+ * <BasaltDataTable data={items} columns={columns} onRowActivate={open}
631
+ * renderCard={(item) => <ItemCard item={item} />} />
632
+ */
633
+ renderCard?: (row: T) => ReactNode
598
634
  /**
599
635
  * Prepends a checkbox column (header = select-all on the page) and arms TanStack's row-selection
600
636
  * feature. Selection is uncontrolled unless `rowSelection` is passed.
@@ -728,6 +764,10 @@ export type BasaltDataTableProps<T> = BasaltProps &
728
764
  * its own card instead of widening the page. Reach for this only to hold a MINIMUM table width
729
765
  * — e.g. so eight columns keep their readable widths and scroll rather than compress.
730
766
  *
767
+ * Setting it also switches the column fold OFF (a declared floor is the caller saying "this
768
+ * table is at least this wide", which folding columns out would contradict), so a `minWidth`
769
+ * table scrolls where the same table without it folds. `maxHeight` alone keeps the fold.
770
+ *
731
771
  * The one table whose wrapper is MEASURED rather than declared is `stickyHeader` with neither
732
772
  * this nor `maxHeight`; see that prop and `useMeasuredContainment` in the implementation for
733
773
  * why, and pair it with `maxHeight` if the table must both scroll and stick.
@@ -795,6 +835,8 @@ function SortIndicator({ direction }: { direction: 'asc' | 'desc' | false }) {
795
835
  )
796
836
  }
797
837
 
838
+ const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const
839
+
798
840
  // ── Numeral cell idiom (docs/DESIGN-SPEC.md §3: "Stat/table numeral: mono, 12-12.5px, weight
799
841
  // 500, ink") — auto-detected from the cell's raw value (no column-level opt-in prop needed) so
800
842
  // every numeric column gets tabular, mono figures with zero call-site configuration.
@@ -940,8 +982,6 @@ function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
940
982
  * that hook, so it carries no styling and needs no CSS-module counterpart. */
941
983
  const FOLD_ID_ATTR = 'data-basalt-fold-id'
942
984
 
943
- const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
944
-
945
985
  /**
946
986
  * Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
947
987
  * still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
@@ -1294,32 +1334,23 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1294
1334
  isLoading = false,
1295
1335
  query,
1296
1336
  skeletonRows = 5,
1297
- initialSorting,
1298
- onSortingChange,
1299
1337
  enableGlobalFilter = false,
1300
1338
  globalFilterPlaceholder = 'Search…',
1301
1339
  searchIcon,
1302
- initialGlobalFilter,
1303
- onGlobalFilterChange,
1304
1340
  facets,
1305
- onColumnFiltersChange,
1306
1341
  manualFiltering = false,
1307
1342
  actions,
1308
1343
  onRowActivate,
1344
+ renderCard,
1309
1345
  enableRowSelection = false,
1310
- rowSelection,
1311
- onRowSelectionChange,
1312
1346
  getRowId,
1313
1347
  bulkActions,
1314
1348
  enablePagination = false,
1315
1349
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
1316
- initialPagination,
1317
- onPaginationChange,
1318
1350
  manualPagination = false,
1319
1351
  rowCount,
1320
1352
  pageCount,
1321
1353
  enablePinning = false,
1322
- initialColumnPinning,
1323
1354
  maxHeight,
1324
1355
  minWidth,
1325
1356
  stickyHeader,
@@ -1332,17 +1363,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1332
1363
  style,
1333
1364
  classNames,
1334
1365
  } = props
1335
- const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
1336
- const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
1337
- const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
1338
- const [pagination, setPagination] = useState<PaginationState>(
1339
- initialPagination ?? { pageIndex: 0, pageSize: pageSizeOptions[0] ?? 10 },
1340
- )
1341
- const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(initialColumnPinning ?? {})
1342
- // Uncontrolled by default; `rowSelection`, when passed, is the truth and this only mirrors it so
1343
- // the updater below has a base to apply against.
1344
- const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
1345
- const rowSelectionState = rowSelection ?? internalRowSelection
1366
+ const tableState = useDataTableState(props, pageSizeOptions[0] ?? 10)
1367
+ const { globalFilter, columnPinning, onGlobalFilterChange: handleGlobalFilterChange } = tableState
1346
1368
 
1347
1369
  // `stickyHeaderOffset` is DROPPED whenever the table owns a scroll container — see
1348
1370
  // `resolvedStickyHeaderOffset` below for why it is always wrong there. Dropping it silently is
@@ -1385,69 +1407,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1385
1407
  ],
1386
1408
  )
1387
1409
 
1388
- const handleSortingChange = useCallback(
1389
- (updater: Updater<SortingState>) => {
1390
- setSorting((prev) => {
1391
- const next = typeof updater === 'function' ? updater(prev) : updater
1392
- onSortingChange?.(next)
1393
- return next
1394
- })
1395
- },
1396
- [onSortingChange],
1397
- )
1398
-
1399
- const handleGlobalFilterChange = useCallback(
1400
- (updater: Updater<string>) => {
1401
- setGlobalFilter((prev) => {
1402
- const next = typeof updater === 'function' ? updater(prev) : updater
1403
- onGlobalFilterChange?.(next)
1404
- return next
1405
- })
1406
- },
1407
- [onGlobalFilterChange],
1408
- )
1409
-
1410
- const handleColumnFiltersChange = useCallback(
1411
- (updater: Updater<ColumnFiltersState>) => {
1412
- setColumnFilters((prev) => {
1413
- const next = typeof updater === 'function' ? updater(prev) : updater
1414
- onColumnFiltersChange?.(next)
1415
- return next
1416
- })
1417
- },
1418
- [onColumnFiltersChange],
1419
- )
1420
-
1421
- const handlePaginationChange = useCallback(
1422
- (updater: Updater<PaginationState>) => {
1423
- setPagination((prev) => {
1424
- const next = typeof updater === 'function' ? updater(prev) : updater
1425
- onPaginationChange?.(next)
1426
- return next
1427
- })
1428
- },
1429
- [onPaginationChange],
1430
- )
1431
-
1432
- const handleRowSelectionChange = useCallback(
1433
- (updater: Updater<RowSelectionState>) => {
1434
- // CONTROLLED: the caller's map is the base AND the only writer. Mirroring it into state too
1435
- // would leave a copy that the caller never moves, and that stale copy becomes the selection
1436
- // the moment `rowSelection` goes back to `undefined` — plus every tick would render twice for
1437
- // one change. Report the next map and let the caller own it.
1438
- if (rowSelection !== undefined) {
1439
- onRowSelectionChange?.(typeof updater === 'function' ? updater(rowSelection) : updater)
1440
- return
1441
- }
1442
- setInternalRowSelection((prev) => {
1443
- const next = typeof updater === 'function' ? updater(prev) : updater
1444
- onRowSelectionChange?.(next)
1445
- return next
1446
- })
1447
- },
1448
- [rowSelection, onRowSelectionChange],
1449
- )
1450
-
1451
1410
  // Facets are TanStack `columnFilters` entries under the hood — inject an exact-match (or
1452
1411
  // any-of, for `multiple`) filterFn onto the matching column so the built-in `includesString`
1453
1412
  // auto-filter (substring match) never misfires against a Select's exact option value.
@@ -1538,8 +1497,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1538
1497
  columnFoldFixedIds: [...excluded],
1539
1498
  }
1540
1499
  }, [columns, columnPinning])
1500
+ // The card projection unmounts the table, so the fold and the containment wrapper go inactive with
1501
+ // it — their measuring effects re-attach to the remounted table when the width returns.
1502
+ const projection = useCardProjection(renderCard !== undefined)
1541
1503
  const columnFold = useColumnFold({
1542
- active: foldEligible,
1504
+ active: foldEligible && !projection.cards,
1543
1505
  order: columnFoldOrder,
1544
1506
  fixedIds: columnFoldFixedIds,
1545
1507
  })
@@ -1548,74 +1510,28 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1548
1510
  [columnFold.folded],
1549
1511
  )
1550
1512
  const hasFolded = columnFold.folded.size > 0
1551
- const [expandedFoldRows, setExpandedFoldRows] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
1552
- const toggleFoldRow = useCallback((rowId: string) => {
1553
- setExpandedFoldRows((current) => {
1554
- const next = new Set(current)
1555
- if (next.has(rowId)) next.delete(rowId)
1556
- else next.add(rowId)
1557
- return next
1558
- })
1559
- }, [])
1560
- // `row.id` defaults to the row's index in `data` (no `getRowId` passed) — stable across a
1561
- // client-side sort/filter/pagination (they reorder or subset the SAME core rows). PRUNED rather
1562
- // than cleared on every `data` change: a non-memoized `data` prop (an inline `.map()`, an
1563
- // unstable query result) hands a brand-new array of the SAME rows on every parent re-render, and
1564
- // clearing on that reference alone collapsed every open disclosure the instant anything upstream
1565
- // re-rendered.
1566
- //
1567
- // Pruning by ID ALONE is only safe with `getRowId` — a caller-declared identity that genuinely
1568
- // survives a page turn or a refetch, so an id no longer present (a manual-pagination page turn, a
1569
- // delete) still has to go, or a differently-shaped record seated at a reused id would silently
1570
- // read as pre-expanded. Without `getRowId` the id IS the array index, and an index survives a
1571
- // refetch trivially — it is still "0", "1", … even when every object behind it is now a
1572
- // completely different record. Keeping an expanded index across THAT would silently attach the
1573
- // old disclosure to a stranger's row. So the index-id lane instead keeps a `prevData` snapshot and
1574
- // asks the narrower, correct question: is the object AT this index still the same reference as
1575
- // last time? A non-memoized array of the SAME rows answers yes at every index (the case above);
1576
- // a page turn or a refetch answers no.
1577
- const prevDataRef = useRef(data)
1578
- useEffect(() => {
1579
- const prevData = prevDataRef.current
1580
- prevDataRef.current = data
1581
- setExpandedFoldRows((current) => {
1582
- if (current.size === 0) return current
1583
- let next: ReadonlySet<string>
1584
- if (getRowId === undefined) {
1585
- next = new Set(
1586
- [...current].filter((id) => {
1587
- const index = Number(id)
1588
- return data[index] !== undefined && data[index] === prevData[index]
1589
- }),
1590
- )
1591
- } else {
1592
- const liveIds = new Set(data.map((row, index) => getRowId(row, index)))
1593
- next = new Set([...current].filter((id) => liveIds.has(id)))
1594
- }
1595
- return next.size === current.size ? current : next
1596
- })
1597
- }, [data, getRowId])
1513
+ const foldRows = useRowDisclosure({ data, getRowId })
1598
1514
 
1599
1515
  const table = useReactTable<T>({
1600
1516
  data,
1601
1517
  columns: tableColumns,
1602
1518
  state: {
1603
- sorting,
1519
+ sorting: tableState.sorting,
1604
1520
  globalFilter,
1605
- columnFilters,
1521
+ columnFilters: tableState.columnFilters,
1606
1522
  columnPinning,
1607
1523
  columnVisibility,
1608
- ...(enablePagination && { pagination }),
1609
- ...(enableRowSelection && { rowSelection: rowSelectionState }),
1524
+ ...(enablePagination && { pagination: tableState.pagination }),
1525
+ ...(enableRowSelection && { rowSelection: tableState.rowSelection }),
1610
1526
  },
1611
- onSortingChange: handleSortingChange,
1527
+ onSortingChange: tableState.onSortingChange,
1612
1528
  onGlobalFilterChange: handleGlobalFilterChange,
1613
- onColumnFiltersChange: handleColumnFiltersChange,
1614
- onColumnPinningChange: setColumnPinning,
1615
- ...(enablePagination && { onPaginationChange: handlePaginationChange }),
1529
+ onColumnFiltersChange: tableState.onColumnFiltersChange,
1530
+ onColumnPinningChange: tableState.onColumnPinningChange,
1531
+ ...(enablePagination && { onPaginationChange: tableState.onPaginationChange }),
1616
1532
  ...(enableRowSelection && {
1617
1533
  enableRowSelection: true,
1618
- onRowSelectionChange: handleRowSelectionChange,
1534
+ onRowSelectionChange: tableState.onRowSelectionChange,
1619
1535
  }),
1620
1536
  ...(getRowId !== undefined && { getRowId }),
1621
1537
  getCoreRowModel: getCoreRowModel(),
@@ -1678,10 +1594,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1678
1594
  // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
1679
1595
  const rows = table.getRowModel().rows
1680
1596
 
1681
- // A capped body or a horizontal floor both need the same native scroll node. Everything else —
1682
- // pinning included — either takes that same container (the uncapped, non-sticky default) or the
1683
- // measured wrapper below; there is no third overflow box. (`scrolls` itself is computed earlier,
1684
- // ahead of the table, because the column fold above needs it too.)
1685
1597
  // `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
1686
1598
  // scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
1687
1599
  // always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
@@ -1700,9 +1612,27 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1700
1612
  // `useMeasuredContainment` drives instead: bare while the table fits, `overflow-x: auto` once it
1701
1613
  // does not. Its docblock carries the full accounting.
1702
1614
  const pageStickyHeader = !scrolls && stickyHeader === true
1703
- const containment = useMeasuredContainment(pageStickyHeader)
1615
+ const containment = useMeasuredContainment(pageStickyHeader && !projection.cards)
1704
1616
  const resolvedStickyHeaderOffset = scrolls ? undefined : stickyHeaderOffset
1705
1617
 
1618
+ // The error branch the table did not have (components audit #3) carries the query's OWN `refetch`
1619
+ // behind Retry, so the failure is reported where the rows would have been — in a row spanning
1620
+ // every rendered column, or in place of the card list.
1621
+ const errorNode = queryError !== undefined && (
1622
+ <ErrorState
1623
+ error={queryError.error}
1624
+ title="Could not load"
1625
+ tier="section"
1626
+ retrying={queryError.fetchStatus === 'fetching'}
1627
+ onRetry={() => void queryError.refetch()}
1628
+ />
1629
+ )
1630
+ const emptyNode = emptyState ?? (
1631
+ <Text c="dimmed" ta="center" size="sm" py="sm">
1632
+ No data to display.
1633
+ </Text>
1634
+ )
1635
+
1706
1636
  const tableNode = (
1707
1637
  <Table
1708
1638
  className={cx(classNames?.table)}
@@ -1750,15 +1680,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1750
1680
  : undefined
1751
1681
  }
1752
1682
  tabIndex={canSort ? 0 : undefined}
1753
- aria-sort={
1754
- canSort
1755
- ? sorted === 'asc'
1756
- ? 'ascending'
1757
- : sorted === 'desc'
1758
- ? 'descending'
1759
- : 'none'
1760
- : undefined
1761
- }
1683
+ aria-sort={canSort ? (sorted ? ARIA_SORT[sorted] : 'none') : undefined}
1762
1684
  style={{
1763
1685
  ...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
1764
1686
  ...(align !== undefined && { textAlign: align }),
@@ -1777,19 +1699,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1777
1699
  </Table.Thead>
1778
1700
  <Table.Tbody>
1779
1701
  {queryError !== undefined ? (
1780
- // The branch the table did not have (components audit #3). It spans every rendered
1781
- // column and carries the query's OWN `refetch` behind Retry, so the failure is reported
1782
- // where the rows would have been — not as an empty table saying nothing failed.
1783
1702
  <Table.Tr>
1784
- <Table.Td colSpan={columnCount}>
1785
- <ErrorState
1786
- error={queryError.error}
1787
- title="Could not load"
1788
- tier="section"
1789
- retrying={queryError.fetchStatus === 'fetching'}
1790
- onRetry={() => void queryError.refetch()}
1791
- />
1792
- </Table.Td>
1703
+ <Table.Td colSpan={columnCount}>{errorNode}</Table.Td>
1793
1704
  </Table.Tr>
1794
1705
  ) : showSkeleton ? (
1795
1706
  Array.from({ length: skeletonRows }, (_, rowIndex) => (
@@ -1803,13 +1714,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1803
1714
  ))
1804
1715
  ) : rows.length === 0 ? (
1805
1716
  <Table.Tr>
1806
- <Table.Td colSpan={columnCount}>
1807
- {emptyState ?? (
1808
- <Text c="dimmed" ta="center" size="sm" py="sm">
1809
- No data to display.
1810
- </Text>
1811
- )}
1812
- </Table.Td>
1717
+ <Table.Td colSpan={columnCount}>{emptyNode}</Table.Td>
1813
1718
  </Table.Tr>
1814
1719
  ) : (
1815
1720
  rows.map((row) => {
@@ -1820,36 +1725,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1820
1725
  ...row.getRightVisibleCells(),
1821
1726
  ]
1822
1727
  : row.getVisibleCells()
1823
- const rowExpanded = hasFolded && expandedFoldRows.has(row.id)
1728
+ const rowExpanded = hasFolded && foldRows.expanded.has(row.id)
1824
1729
  return (
1825
1730
  <Fragment key={row.id}>
1826
1731
  <Table.Tr
1827
1732
  {...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
1828
- {...(onRowActivate !== undefined && {
1829
- className: classes.activatable,
1830
- 'data-activatable': true,
1831
- tabIndex: 0,
1832
- onClick: () => onRowActivate(row.original),
1833
- // Enter only. Space is the browser's own page-scroll on a focused non-button, and
1834
- // stealing it from a keyboard reader moving down a long table costs more than the
1835
- // second activation key buys.
1836
- onKeyDown: (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
1837
- if (event.key !== 'Enter') return
1838
- // Only the ROW's own Enter. A cell may hold a button, a link or the selection
1839
- // checkbox, and keydown bubbles — so without this an Enter on a nested control
1840
- // fired that control AND opened the row's detail behind it.
1841
- if (event.target !== event.currentTarget) return
1842
- event.preventDefault()
1843
- onRowActivate(row.original)
1844
- },
1845
- })}
1733
+ {...activationProps(onRowActivate, row.original)}
1846
1734
  >
1847
1735
  {hasFolded && (
1848
1736
  <Table.Td className={classes.foldToggleCell}>
1849
1737
  <FoldToggle
1850
1738
  expanded={rowExpanded}
1851
1739
  controlsId={`${row.id}-fold`}
1852
- onToggle={() => toggleFoldRow(row.id)}
1740
+ onToggle={() => foldRows.toggle(row.id)}
1853
1741
  />
1854
1742
  </Table.Td>
1855
1743
  )}
@@ -1898,6 +1786,25 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1898
1786
  </Table>
1899
1787
  )
1900
1788
 
1789
+ // The `renderCard` body — the same processed rows, states and activation as the table's.
1790
+ const renderCardList = () => (
1791
+ <Stack gap="xs" className={cx(classNames?.table)}>
1792
+ {queryError !== undefined
1793
+ ? errorNode
1794
+ : showSkeleton
1795
+ ? Array.from({ length: skeletonRows }, (_, index) => (
1796
+ <Skeleton key={`skeleton-${index}`} height={48} radius="md" />
1797
+ ))
1798
+ : rows.length === 0
1799
+ ? emptyNode
1800
+ : rows.map((row) => (
1801
+ <Box key={row.id} {...activationProps(onRowActivate, row.original)}>
1802
+ {renderCard?.(row.original)}
1803
+ </Box>
1804
+ ))}
1805
+ </Stack>
1806
+ )
1807
+
1901
1808
  const paginationState = table.getState().pagination
1902
1809
  // The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
1903
1810
  // Select renders EMPTY when its `value` matches no row, so a table opened at
@@ -1911,7 +1818,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1911
1818
  const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
1912
1819
 
1913
1820
  return (
1914
- <div className={cx(classNames?.root, className)} {...(style !== undefined && { style })}>
1821
+ <div
1822
+ ref={projection.rootRef}
1823
+ className={cx(classNames?.root, className)}
1824
+ {...(style !== undefined && { style })}
1825
+ >
1915
1826
  {/*
1916
1827
  * ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
1917
1828
  * the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
@@ -1985,7 +1896,15 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1985
1896
  {bulkBarActions !== undefined && (
1986
1897
  <BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
1987
1898
  )}
1988
- {!foldEligible ? (
1899
+ {projection.cards ? (
1900
+ maxHeight === undefined ? (
1901
+ renderCardList()
1902
+ ) : (
1903
+ <ScrollArea.Autosize mah={maxHeight} type="hover" scrollbars="y">
1904
+ {renderCardList()}
1905
+ </ScrollArea.Autosize>
1906
+ )
1907
+ ) : !foldEligible ? (
1989
1908
  // An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
1990
1909
  // never engages here (see `foldEligible`, above), so this shape keeps its exact
1991
1910
  // pre-existing DOM: no extra measuring wrapper for a table nothing folds.
@@ -2010,15 +1929,10 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
2010
1929
  >
2011
1930
  {tableNode}
2012
1931
  </div>
2013
- ) : maxHeight !== undefined ? (
2014
- // `pageStickyHeader` is already false whenever `maxHeight` is set (`scrolls` above), so
2015
- // this is the ONE remaining case: a vertical cap with no sticky header to measure —
2016
- // still wants its own `ScrollArea` scrollport, just with no `minWidth` floor.
2017
- <Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
2018
- {tableNode}
2019
- </Table.ScrollContainer>
2020
1932
  ) : (
2021
- <Table.ScrollContainer type="native" minWidth={0}>
1933
+ // `pageStickyHeader` is false whenever `maxHeight` is set (`scrolls` above), so a vertical
1934
+ // cap lands here — its own `ScrollArea` scrollport, just with no `minWidth` floor.
1935
+ <Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
2022
1936
  {tableNode}
2023
1937
  </Table.ScrollContainer>
2024
1938
  )}
@@ -2038,6 +1952,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
2038
1952
  {totalIsAuthoritative ? ` of ${total}` : ''}
2039
1953
  </Text>
2040
1954
  <Group gap="xs" align="center">
1955
+ {/* theme-allow control-outside-home — the table's own page-size setting, not a page
1956
+ filter: its state is the table's pagination, owned inside this component. */}
2041
1957
  <Select
2042
1958
  size="ctl"
2043
1959
  radius="md"
@@ -14,9 +14,10 @@
14
14
  * containment and `.grid` is the queried child, the same split `StatGroup`'s `.container`/`.root`
15
15
  * uses — a container cannot reliably query ITSELF for a layout-affecting property (measured here:
16
16
  * Chrome silently keeps the un-queried value even once the container's own width crosses the
17
- * condition), only a descendant. The `599.9px`/`47.99375em` literals: 600px is roughly the point
17
+ * condition), only a descendant. The `599.9px` literal: 600px is roughly the point
18
18
  * two ~250px+ cells stop feeling cramped; `@container`/`@media` conditions cannot read a custom
19
- * property, so both are written numbers, not `--vx-*` refs.
19
+ * property, so it is a written number, not a `--vx-*` ref. No viewport fallback — see
20
+ * `stat-group.module.css`: nothing that renders basalt lacks `@container`.
20
21
  */
21
22
 
22
23
  /* ── FormSection ─────────────────────────────────────────────────────────────────────────────── */
@@ -102,17 +103,6 @@
102
103
  }
103
104
  }
104
105
 
105
- /* Pre-container-query fallback (Safari < 16, Chrome < 106, Firefox < 110) — the old viewport law,
106
- * reachable only where `@container` itself cannot parse (same reasoning as `stat-group.module.css`). */
107
- @supports not (container-type: inline-size) {
108
- @media (max-width: 47.99375em) {
109
- .grid {
110
- grid-template-columns: minmax(0, 1fr);
111
- gap: var(--vx-space-stack-xs);
112
- }
113
- }
114
- }
115
-
116
106
  /* ── FormGroup ───────────────────────────────────────────────────────────────────────────────── */
117
107
 
118
108
  /* Same UA reset as `.control`, same reason. */
@@ -148,9 +138,12 @@
148
138
  gap: var(--vx-space-stack-xs);
149
139
  }
150
140
 
141
+ /* Bottom-aligned, not stretched: a trailing button beside a labelled FormRow keeps its own height
142
+ and lines up with the input, not with the label above it. */
151
143
  .groupControls[data-direction='row'] {
152
144
  flex-direction: row;
153
145
  flex-wrap: wrap;
146
+ align-items: flex-end;
154
147
  gap: var(--vx-space-control-gap);
155
148
  }
156
149
 
@@ -213,7 +213,12 @@ export type FormGroupProps = BasaltProps & {
213
213
  error?: ReactNode
214
214
  /** Marks the group required — same two-channel treatment as `FormRow`. */
215
215
  required?: boolean
216
- /** `column` stacks the options; `row` lays them out inline and wraps. @default 'column' */
216
+ /**
217
+ * `column` stacks the options; `row` lays them out inline, wraps, and bottom-aligns them, so a
218
+ * trailing button sits level with the inputs instead of stretching to the tallest child. Give a
219
+ * `FormRow` inside a `row` group (or any flex row) a definite width — it is an inline-size
220
+ * container, so its intrinsic width is zero and it collapses otherwise. @default 'column'
221
+ */
217
222
  direction?: 'row' | 'column'
218
223
  /** The cluster — checkboxes, radios, chips. */
219
224
  children: ReactNode