basalt-ui 1.31.0 → 1.32.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 (91) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/MIGRATING.md +101 -17
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +159 -42
  5. package/configs/oxlint-plugin.test.ts +182 -0
  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 +33 -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.module.css +3 -13
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +6 -5
  50. package/dist/notifications/center.d.ts.map +1 -1
  51. package/dist/provider/index.d.ts.map +1 -1
  52. package/dist/provider/index.js +12 -10
  53. package/dist/shell/aside-docking.d.ts +31 -0
  54. package/dist/shell/aside-docking.d.ts.map +1 -0
  55. package/dist/shell/aside-docking.js +19 -0
  56. package/dist/shell/index.d.ts +0 -11
  57. package/dist/shell/index.d.ts.map +1 -1
  58. package/dist/shell/index.js +13 -14
  59. package/dist/shell/use-breakpoint.d.ts +1 -1
  60. package/dist/theme/floating.module.css +1 -0
  61. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  62. package/llms.txt +2 -2
  63. package/package.json +2 -1
  64. package/src/charts/kinds/BandStrip.tsx +18 -64
  65. package/src/charts/kinds/DualPanel.tsx +4 -24
  66. package/src/charts/kinds/MirroredBars.tsx +12 -66
  67. package/src/charts/kinds/band-chrome.tsx +91 -0
  68. package/src/charts/primitives/CartesianChart.tsx +79 -122
  69. package/src/charts/primitives/ChartCard.tsx +5 -27
  70. package/src/charts/primitives/ChartFrame.tsx +37 -76
  71. package/src/charts/primitives/ChartLegend.tsx +7 -154
  72. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  73. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  74. package/src/charts/primitives/chart-card-context.ts +25 -16
  75. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  76. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  77. package/src/controls/controls.module.css +5 -0
  78. package/src/controls/sync-button.module.css +1 -0
  79. package/src/dashboard/stat-group.module.css +19 -64
  80. package/src/dashboard/widget-grid.module.css +11 -38
  81. package/src/data/data-table-state.ts +218 -0
  82. package/src/data/data-table.tsx +127 -218
  83. package/src/forms/form-layout.module.css +3 -13
  84. package/src/guard/index.ts +21 -16
  85. package/src/notifications/center.tsx +3 -0
  86. package/src/provider/index.tsx +18 -17
  87. package/src/shell/aside-docking.ts +54 -0
  88. package/src/shell/index.tsx +15 -37
  89. package/src/shell/use-breakpoint.ts +2 -2
  90. package/src/theme/floating.module.css +1 -0
  91. package/src/theme-lab/derive-controls.tsx +2 -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,37 @@ 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). The selection
612
+ * checkbox is not projected — a card list with a selection keeps its bulk bar but no ticks.
613
+ *
614
+ * An activatable card takes `tabIndex=0` + `data-activatable`, never `role="button"`: a card is
615
+ * where a row's actions and links land (argo's all do), and a `button` role makes every
616
+ * descendant presentational, so those nested controls would vanish from the accessibility tree.
617
+ * The affordance is the cursor and the focus ring, as on the row. `maxHeight` caps the card list
618
+ * in a `ScrollArea`; `minWidth` does not apply — it is a horizontal floor for columns, and a
619
+ * card list has none.
620
+ *
621
+ * Reach for it when the row carries more than the columns a phone can hold (a status + label +
622
+ * age header, a clamped title, a prose line). A table that only needs to shed a column or two
623
+ * already has that: the column fold hides the lowest-priority columns behind a per-row
624
+ * disclosure with no prop at all. Server render and first paint are the table.
625
+ *
626
+ * @example
627
+ * <BasaltDataTable data={items} columns={columns} onRowActivate={open}
628
+ * renderCard={(item) => <ItemCard item={item} />} />
629
+ */
630
+ renderCard?: (row: T) => ReactNode
598
631
  /**
599
632
  * Prepends a checkbox column (header = select-all on the page) and arms TanStack's row-selection
600
633
  * feature. Selection is uncontrolled unless `rowSelection` is passed.
@@ -795,6 +828,8 @@ function SortIndicator({ direction }: { direction: 'asc' | 'desc' | false }) {
795
828
  )
796
829
  }
797
830
 
831
+ const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const
832
+
798
833
  // ── Numeral cell idiom (docs/DESIGN-SPEC.md §3: "Stat/table numeral: mono, 12-12.5px, weight
799
834
  // 500, ink") — auto-detected from the cell's raw value (no column-level opt-in prop needed) so
800
835
  // every numeric column gets tabular, mono figures with zero call-site configuration.
@@ -940,8 +975,6 @@ function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
940
975
  * that hook, so it carries no styling and needs no CSS-module counterpart. */
941
976
  const FOLD_ID_ATTR = 'data-basalt-fold-id'
942
977
 
943
- const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
944
-
945
978
  /**
946
979
  * Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
947
980
  * still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
@@ -1294,32 +1327,23 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1294
1327
  isLoading = false,
1295
1328
  query,
1296
1329
  skeletonRows = 5,
1297
- initialSorting,
1298
- onSortingChange,
1299
1330
  enableGlobalFilter = false,
1300
1331
  globalFilterPlaceholder = 'Search…',
1301
1332
  searchIcon,
1302
- initialGlobalFilter,
1303
- onGlobalFilterChange,
1304
1333
  facets,
1305
- onColumnFiltersChange,
1306
1334
  manualFiltering = false,
1307
1335
  actions,
1308
1336
  onRowActivate,
1337
+ renderCard,
1309
1338
  enableRowSelection = false,
1310
- rowSelection,
1311
- onRowSelectionChange,
1312
1339
  getRowId,
1313
1340
  bulkActions,
1314
1341
  enablePagination = false,
1315
1342
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
1316
- initialPagination,
1317
- onPaginationChange,
1318
1343
  manualPagination = false,
1319
1344
  rowCount,
1320
1345
  pageCount,
1321
1346
  enablePinning = false,
1322
- initialColumnPinning,
1323
1347
  maxHeight,
1324
1348
  minWidth,
1325
1349
  stickyHeader,
@@ -1332,17 +1356,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1332
1356
  style,
1333
1357
  classNames,
1334
1358
  } = 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
1359
+ const tableState = useDataTableState(props, pageSizeOptions[0] ?? 10)
1360
+ const { globalFilter, columnPinning, onGlobalFilterChange: handleGlobalFilterChange } = tableState
1346
1361
 
1347
1362
  // `stickyHeaderOffset` is DROPPED whenever the table owns a scroll container — see
1348
1363
  // `resolvedStickyHeaderOffset` below for why it is always wrong there. Dropping it silently is
@@ -1385,69 +1400,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1385
1400
  ],
1386
1401
  )
1387
1402
 
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
1403
  // Facets are TanStack `columnFilters` entries under the hood — inject an exact-match (or
1452
1404
  // any-of, for `multiple`) filterFn onto the matching column so the built-in `includesString`
1453
1405
  // auto-filter (substring match) never misfires against a Select's exact option value.
@@ -1538,8 +1490,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1538
1490
  columnFoldFixedIds: [...excluded],
1539
1491
  }
1540
1492
  }, [columns, columnPinning])
1493
+ // The card projection unmounts the table, so the fold and the containment wrapper go inactive with
1494
+ // it — their measuring effects re-attach to the remounted table when the width returns.
1495
+ const projection = useCardProjection(renderCard !== undefined)
1541
1496
  const columnFold = useColumnFold({
1542
- active: foldEligible,
1497
+ active: foldEligible && !projection.cards,
1543
1498
  order: columnFoldOrder,
1544
1499
  fixedIds: columnFoldFixedIds,
1545
1500
  })
@@ -1548,74 +1503,28 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1548
1503
  [columnFold.folded],
1549
1504
  )
1550
1505
  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])
1506
+ const foldRows = useRowDisclosure({ data, getRowId })
1598
1507
 
1599
1508
  const table = useReactTable<T>({
1600
1509
  data,
1601
1510
  columns: tableColumns,
1602
1511
  state: {
1603
- sorting,
1512
+ sorting: tableState.sorting,
1604
1513
  globalFilter,
1605
- columnFilters,
1514
+ columnFilters: tableState.columnFilters,
1606
1515
  columnPinning,
1607
1516
  columnVisibility,
1608
- ...(enablePagination && { pagination }),
1609
- ...(enableRowSelection && { rowSelection: rowSelectionState }),
1517
+ ...(enablePagination && { pagination: tableState.pagination }),
1518
+ ...(enableRowSelection && { rowSelection: tableState.rowSelection }),
1610
1519
  },
1611
- onSortingChange: handleSortingChange,
1520
+ onSortingChange: tableState.onSortingChange,
1612
1521
  onGlobalFilterChange: handleGlobalFilterChange,
1613
- onColumnFiltersChange: handleColumnFiltersChange,
1614
- onColumnPinningChange: setColumnPinning,
1615
- ...(enablePagination && { onPaginationChange: handlePaginationChange }),
1522
+ onColumnFiltersChange: tableState.onColumnFiltersChange,
1523
+ onColumnPinningChange: tableState.onColumnPinningChange,
1524
+ ...(enablePagination && { onPaginationChange: tableState.onPaginationChange }),
1616
1525
  ...(enableRowSelection && {
1617
1526
  enableRowSelection: true,
1618
- onRowSelectionChange: handleRowSelectionChange,
1527
+ onRowSelectionChange: tableState.onRowSelectionChange,
1619
1528
  }),
1620
1529
  ...(getRowId !== undefined && { getRowId }),
1621
1530
  getCoreRowModel: getCoreRowModel(),
@@ -1678,10 +1587,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1678
1587
  // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
1679
1588
  const rows = table.getRowModel().rows
1680
1589
 
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
1590
  // `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
1686
1591
  // scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
1687
1592
  // always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
@@ -1700,9 +1605,27 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1700
1605
  // `useMeasuredContainment` drives instead: bare while the table fits, `overflow-x: auto` once it
1701
1606
  // does not. Its docblock carries the full accounting.
1702
1607
  const pageStickyHeader = !scrolls && stickyHeader === true
1703
- const containment = useMeasuredContainment(pageStickyHeader)
1608
+ const containment = useMeasuredContainment(pageStickyHeader && !projection.cards)
1704
1609
  const resolvedStickyHeaderOffset = scrolls ? undefined : stickyHeaderOffset
1705
1610
 
1611
+ // The error branch the table did not have (components audit #3) carries the query's OWN `refetch`
1612
+ // behind Retry, so the failure is reported where the rows would have been — in a row spanning
1613
+ // every rendered column, or in place of the card list.
1614
+ const errorNode = queryError !== undefined && (
1615
+ <ErrorState
1616
+ error={queryError.error}
1617
+ title="Could not load"
1618
+ tier="section"
1619
+ retrying={queryError.fetchStatus === 'fetching'}
1620
+ onRetry={() => void queryError.refetch()}
1621
+ />
1622
+ )
1623
+ const emptyNode = emptyState ?? (
1624
+ <Text c="dimmed" ta="center" size="sm" py="sm">
1625
+ No data to display.
1626
+ </Text>
1627
+ )
1628
+
1706
1629
  const tableNode = (
1707
1630
  <Table
1708
1631
  className={cx(classNames?.table)}
@@ -1750,15 +1673,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1750
1673
  : undefined
1751
1674
  }
1752
1675
  tabIndex={canSort ? 0 : undefined}
1753
- aria-sort={
1754
- canSort
1755
- ? sorted === 'asc'
1756
- ? 'ascending'
1757
- : sorted === 'desc'
1758
- ? 'descending'
1759
- : 'none'
1760
- : undefined
1761
- }
1676
+ aria-sort={canSort ? (sorted ? ARIA_SORT[sorted] : 'none') : undefined}
1762
1677
  style={{
1763
1678
  ...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
1764
1679
  ...(align !== undefined && { textAlign: align }),
@@ -1777,19 +1692,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1777
1692
  </Table.Thead>
1778
1693
  <Table.Tbody>
1779
1694
  {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
1695
  <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>
1696
+ <Table.Td colSpan={columnCount}>{errorNode}</Table.Td>
1793
1697
  </Table.Tr>
1794
1698
  ) : showSkeleton ? (
1795
1699
  Array.from({ length: skeletonRows }, (_, rowIndex) => (
@@ -1803,13 +1707,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1803
1707
  ))
1804
1708
  ) : rows.length === 0 ? (
1805
1709
  <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>
1710
+ <Table.Td colSpan={columnCount}>{emptyNode}</Table.Td>
1813
1711
  </Table.Tr>
1814
1712
  ) : (
1815
1713
  rows.map((row) => {
@@ -1820,36 +1718,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1820
1718
  ...row.getRightVisibleCells(),
1821
1719
  ]
1822
1720
  : row.getVisibleCells()
1823
- const rowExpanded = hasFolded && expandedFoldRows.has(row.id)
1721
+ const rowExpanded = hasFolded && foldRows.expanded.has(row.id)
1824
1722
  return (
1825
1723
  <Fragment key={row.id}>
1826
1724
  <Table.Tr
1827
1725
  {...(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
- })}
1726
+ {...activationProps(onRowActivate, row.original)}
1846
1727
  >
1847
1728
  {hasFolded && (
1848
1729
  <Table.Td className={classes.foldToggleCell}>
1849
1730
  <FoldToggle
1850
1731
  expanded={rowExpanded}
1851
1732
  controlsId={`${row.id}-fold`}
1852
- onToggle={() => toggleFoldRow(row.id)}
1733
+ onToggle={() => foldRows.toggle(row.id)}
1853
1734
  />
1854
1735
  </Table.Td>
1855
1736
  )}
@@ -1898,6 +1779,25 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1898
1779
  </Table>
1899
1780
  )
1900
1781
 
1782
+ // The `renderCard` body — the same processed rows, states and activation as the table's.
1783
+ const renderCardList = () => (
1784
+ <Stack gap="xs" className={cx(classNames?.table)}>
1785
+ {queryError !== undefined
1786
+ ? errorNode
1787
+ : showSkeleton
1788
+ ? Array.from({ length: skeletonRows }, (_, index) => (
1789
+ <Skeleton key={`skeleton-${index}`} height={48} radius="md" />
1790
+ ))
1791
+ : rows.length === 0
1792
+ ? emptyNode
1793
+ : rows.map((row) => (
1794
+ <Box key={row.id} {...activationProps(onRowActivate, row.original)}>
1795
+ {renderCard?.(row.original)}
1796
+ </Box>
1797
+ ))}
1798
+ </Stack>
1799
+ )
1800
+
1901
1801
  const paginationState = table.getState().pagination
1902
1802
  // The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
1903
1803
  // Select renders EMPTY when its `value` matches no row, so a table opened at
@@ -1911,7 +1811,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1911
1811
  const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
1912
1812
 
1913
1813
  return (
1914
- <div className={cx(classNames?.root, className)} {...(style !== undefined && { style })}>
1814
+ <div
1815
+ ref={projection.rootRef}
1816
+ className={cx(classNames?.root, className)}
1817
+ {...(style !== undefined && { style })}
1818
+ >
1915
1819
  {/*
1916
1820
  * ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
1917
1821
  * the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
@@ -1985,7 +1889,15 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1985
1889
  {bulkBarActions !== undefined && (
1986
1890
  <BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
1987
1891
  )}
1988
- {!foldEligible ? (
1892
+ {projection.cards ? (
1893
+ maxHeight === undefined ? (
1894
+ renderCardList()
1895
+ ) : (
1896
+ <ScrollArea.Autosize mah={maxHeight} type="hover" scrollbars="y">
1897
+ {renderCardList()}
1898
+ </ScrollArea.Autosize>
1899
+ )
1900
+ ) : !foldEligible ? (
1989
1901
  // An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
1990
1902
  // never engages here (see `foldEligible`, above), so this shape keeps its exact
1991
1903
  // pre-existing DOM: no extra measuring wrapper for a table nothing folds.
@@ -2010,15 +1922,10 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
2010
1922
  >
2011
1923
  {tableNode}
2012
1924
  </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
1925
  ) : (
2021
- <Table.ScrollContainer type="native" minWidth={0}>
1926
+ // `pageStickyHeader` is false whenever `maxHeight` is set (`scrolls` above), so a vertical
1927
+ // cap lands here — its own `ScrollArea` scrollport, just with no `minWidth` floor.
1928
+ <Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
2022
1929
  {tableNode}
2023
1930
  </Table.ScrollContainer>
2024
1931
  )}
@@ -2038,6 +1945,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
2038
1945
  {totalIsAuthoritative ? ` of ${total}` : ''}
2039
1946
  </Text>
2040
1947
  <Group gap="xs" align="center">
1948
+ {/* theme-allow control-outside-home — the table's own page-size setting, not a page
1949
+ filter: its state is the table's pagination, owned inside this component. */}
2041
1950
  <Select
2042
1951
  size="ctl"
2043
1952
  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. */
@@ -152,7 +152,7 @@ const CSS_WIDTH_MEDIA_QUERY = new RegExp(
152
152
  * An `@container` condition — the declared NAME (optional: `@container (min-width: …)` with no
153
153
  * name is legal CSS, scoping to the nearest ancestor container regardless of its name) plus every
154
154
  * raw boundary across the WHOLE at-rule, chained `and (...)` clauses included
155
- * (`@container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px)` —
155
+ * (`@container basalt-grid (min-width: 800px) and (max-width: 1199.9px)` —
156
156
  * `stat-group.module.css` ships exactly this shape). Capturing only the first parenthesized clause
157
157
  * would silently skip every boundary after the first `and`; group 2 here is the full run of
158
158
  * `(...)`/`and` text so `CSS_CONTAINER_LITERAL`'s own `matchAll` below finds all of them.
@@ -180,11 +180,8 @@ const CSS_NON_COARSE_POINTER_QUERY =
180
180
  * `basalt-form`/`basalt-main`/`basalt-grid` are the declared remaining three, reserved here so a
181
181
  * consumer adopting one ahead of the framework does not immediately trip this guard.
182
182
  *
183
- * Two container names already shipped ahead of this table — `basalt-stat-group` and
184
- * `basalt-widget-grid` (`src/tokens/breakpoint-literals.test.ts`'s own `LEGACY_CONTAINER_LITERALS`
185
- * names them) — predate the declared table and are deliberately NOT in this set: they surface as
186
- * `warn` findings against basalt's own source the same way any other kind's incumbent violations do
187
- * at grace time, rather than being silently grandfathered into a shipped rule's allowlist.
183
+ * `basalt-grid` is also what `StatGroup`/`WidgetGrid` declare (`src/dashboard/*.module.css`) —
184
+ * they shipped as `basalt-stat-group`/`basalt-widget-grid` until 1.32.0 moved them onto this table.
188
185
  */
189
186
  const DECLARED_CONTAINER_NAMES = new Set([
190
187
  'basalt-card',
@@ -229,7 +226,14 @@ function isSanctionedShellMediaQuery(relPath: string, query: string): boolean {
229
226
  // the comparison/chained spellings too, with no shape-specific regex of its own. All four units
230
227
  // are captured (not `em` only) so a `px` width — never a `SIZE_CLASS_LITERALS` member — correctly
231
228
  // fails the check below, rather than `.every()` vacuously passing on an empty match.
232
- const widths = query.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? []
229
+ //
230
+ // But `query` is the whole at-rule prelude up to the width condition, so it can also carry a
231
+ // NON-width clause — the shell's own landscape rule is `(max-height: 480px) and (orientation:
232
+ // landscape) and (max-width: 52.49375em)`, and reading its `480px` height as an off-table width
233
+ // flagged the framework's own sanctioned implementation. Only the parenthesized clauses that name
234
+ // `width` are read.
235
+ const widthClauses = query.match(/\([^()]*\bwidth\b[^()]*\)/g) ?? []
236
+ const widths = widthClauses.flatMap((c) => c.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? [])
233
237
  return widths.length > 0 && widths.every((w) => SIZE_CLASS_LITERALS.has(w))
234
238
  }
235
239
 
@@ -860,8 +864,9 @@ export type GraceEntry = { since: string; promote: string; why: string }
860
864
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
861
865
  'raw-selection-control': {
862
866
  since: '1.26.0',
863
- promote: '1.32.0',
867
+ promote: '1.33.0',
864
868
  why:
869
+ "Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
865
870
  'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
866
871
  'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
867
872
  'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
@@ -880,15 +885,15 @@ export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
880
885
  },
881
886
  'raw-media-query': {
882
887
  since: '1.31.0',
883
- promote: '1.32.0',
888
+ promote: '1.33.0',
884
889
  why:
885
- 'new in the wave-11 responsive/touch guards — the CSS-text ' +
886
- "twin of the oxlint plugin's `basalt/raw-breakpoint`. Reaching CSS is a NEW file type for this " +
887
- "check, same reasoning as `css-raw-surface`, and basalt's own `src/dashboard/stat-group.module" +
888
- '.css` / `widget-grid.module.css` already declare `@container` names (`basalt-stat-group`, ' +
889
- "`basalt-widget-grid`) outside the declared four-name table, plus `shell/**`'s own " +
890
- "legitimate width `@media` rules — real incumbents in basalt's own tree at ship time, so this " +
891
- 'ships warn rather than error from the start. Promotes with its AST twin at 1.32.0.',
890
+ "The CSS-text twin of the oxlint plugin's `basalt/raw-breakpoint` — ONE LAW, TWO LANES, ONE " +
891
+ 'PROMOTION, so it moves with that rule and never alone (`configs/oxlint-plugin.test.ts` › twin lanes share one promotion pins the pair). New in ' +
892
+ 'the wave-11 responsive/touch guards; reaching CSS is a NEW file type for this check, same ' +
893
+ "reasoning as `css-raw-surface`, with incumbents in basalt's own tree at ship time, so it " +
894
+ 'shipped warn. Extended 1.32.0 -> 1.33.0 with its twin: 1.31.0 named ' +
895
+ 'no reachable container primitive, and 1.32.0 is the first minor that documents one ' +
896
+ '(`CONTAINER_CLASSES` + the declared `@container` names, MIGRATING § `basalt/raw-breakpoint`).',
892
897
  },
893
898
  }
894
899