basalt-ui 1.30.2 → 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 (357) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/MIGRATING.md +256 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +5 -5
  6. package/configs/oxlint-plugin.js +371 -4
  7. package/configs/oxlint-plugin.test.ts +327 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +47 -82
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +138 -145
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +68 -111
  47. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  48. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  49. package/dist/charts/kinds/band-chrome.js +71 -0
  50. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  51. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  52. package/dist/charts/kinds/donut-layout.js +52 -0
  53. package/dist/charts/layout/auto-margin.d.ts +7 -2
  54. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  55. package/dist/charts/layout/auto-margin.js +12 -3
  56. package/dist/charts/layout/end-labels.d.ts +64 -0
  57. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  58. package/dist/charts/layout/end-labels.js +76 -0
  59. package/dist/charts/primitives/Axes.d.ts +32 -4
  60. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  61. package/dist/charts/primitives/Axes.js +62 -16
  62. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  63. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  64. package/dist/charts/primitives/CartesianChart.js +264 -145
  65. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  66. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartCard.js +56 -21
  68. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  69. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartFrame.js +116 -34
  71. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  72. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  73. package/dist/charts/primitives/ChartLegend.js +86 -73
  74. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  75. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  76. package/dist/charts/primitives/ChartTooltip.js +20 -4
  77. package/dist/charts/primitives/Crosshair.js +2 -2
  78. package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
  79. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  80. package/dist/charts/primitives/HoverOverlay.js +54 -27
  81. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  82. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  83. package/dist/charts/primitives/chart-card-context.d.ts +33 -0
  84. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  85. package/dist/charts/primitives/chart-card-context.js +18 -0
  86. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  87. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  88. package/dist/charts/primitives/chart-frame-layout.js +75 -34
  89. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  90. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  91. package/dist/charts/primitives/chart-layout.js +175 -0
  92. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  93. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  94. package/dist/charts/primitives/chart-tier.js +42 -13
  95. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  96. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  97. package/dist/charts/primitives/legend-disclosure.js +153 -0
  98. package/dist/charts/series.d.ts +4 -1
  99. package/dist/charts/series.d.ts.map +1 -1
  100. package/dist/charts/utils/ticks.d.ts +27 -8
  101. package/dist/charts/utils/ticks.d.ts.map +1 -1
  102. package/dist/charts/utils/ticks.js +35 -11
  103. package/dist/common/errors.d.ts +11 -1
  104. package/dist/common/errors.d.ts.map +1 -1
  105. package/dist/common/errors.js +16 -2
  106. package/dist/common/hit-floor.d.ts +10 -0
  107. package/dist/common/hit-floor.d.ts.map +1 -0
  108. package/dist/common/hit-floor.js +6 -0
  109. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  110. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  111. package/dist/common/use-measured-widths.d.ts +37 -0
  112. package/dist/common/use-measured-widths.d.ts.map +1 -0
  113. package/dist/common/use-measured-widths.js +51 -0
  114. package/dist/common/use-media-query.d.ts +6 -0
  115. package/dist/common/use-media-query.d.ts.map +1 -0
  116. package/dist/common/use-media-query.js +40 -0
  117. package/dist/common/validate.d.ts +9 -0
  118. package/dist/common/validate.d.ts.map +1 -1
  119. package/dist/common/validate.js +9 -4
  120. package/dist/content/article-layout.d.ts +4 -2
  121. package/dist/content/article-layout.d.ts.map +1 -1
  122. package/dist/content/article-layout.js +43 -3
  123. package/dist/content/article-layout.module.css +45 -4
  124. package/dist/content/article-layout.module.css.d.ts +3 -0
  125. package/dist/content/code-block.d.ts.map +1 -1
  126. package/dist/content/code-block.js +40 -2
  127. package/dist/content/code-block.module.css +32 -2
  128. package/dist/content/prose.module.css +13 -8
  129. package/dist/controls/actions.d.ts +29 -6
  130. package/dist/controls/actions.d.ts.map +1 -1
  131. package/dist/controls/actions.js +158 -27
  132. package/dist/controls/actions.module.css +14 -1
  133. package/dist/controls/actions.module.css.d.ts +1 -0
  134. package/dist/controls/controls.module.css +13 -31
  135. package/dist/controls/enum-filter.js +2 -2
  136. package/dist/controls/filter-context.d.ts +10 -0
  137. package/dist/controls/filter-context.d.ts.map +1 -1
  138. package/dist/controls/filter-context.js +2 -0
  139. package/dist/controls/filter-pill.d.ts.map +1 -1
  140. package/dist/controls/filter-pill.js +1 -0
  141. package/dist/controls/index.d.ts +1 -1
  142. package/dist/controls/index.d.ts.map +1 -1
  143. package/dist/controls/index.js +0 -2
  144. package/dist/controls/multi-select-filter.js +2 -2
  145. package/dist/controls/sync-button.module.css +2 -1
  146. package/dist/dashboard/section.d.ts.map +1 -1
  147. package/dist/dashboard/section.js +1 -0
  148. package/dist/dashboard/section.module.css +1 -26
  149. package/dist/dashboard/stat-card.d.ts +1 -1
  150. package/dist/dashboard/stat-card.d.ts.map +1 -1
  151. package/dist/dashboard/stat-card.js +4 -1
  152. package/dist/dashboard/stat-card.module.css +6 -9
  153. package/dist/dashboard/stat-group.module.css +33 -66
  154. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  155. package/dist/dashboard/widget-grid.module.css +11 -38
  156. package/dist/dashboard/widget-header.d.ts +14 -4
  157. package/dist/dashboard/widget-header.d.ts.map +1 -1
  158. package/dist/dashboard/widget-header.js +23 -6
  159. package/dist/dashboard/widget-header.module.css +145 -133
  160. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  161. package/dist/data/data-table-state.d.ts +83 -0
  162. package/dist/data/data-table-state.d.ts.map +1 -0
  163. package/dist/data/data-table-state.js +139 -0
  164. package/dist/data/data-table.d.ts +56 -2
  165. package/dist/data/data-table.d.ts.map +1 -1
  166. package/dist/data/data-table.js +436 -336
  167. package/dist/data/data-table.module.css +94 -0
  168. package/dist/data/data-table.module.css.d.ts +11 -0
  169. package/dist/data/virtual-list.d.ts.map +1 -1
  170. package/dist/data/virtual-list.js +10 -0
  171. package/dist/forms/form-layout.d.ts.map +1 -1
  172. package/dist/forms/form-layout.js +3 -3
  173. package/dist/forms/form-layout.module.css +20 -5
  174. package/dist/forms/form-layout.module.css.d.ts +1 -0
  175. package/dist/guard/index.d.ts +8 -2
  176. package/dist/guard/index.d.ts.map +1 -1
  177. package/dist/guard/index.js +68 -2
  178. package/dist/guard/types.d.ts +3 -3
  179. package/dist/guard/types.d.ts.map +1 -1
  180. package/dist/index.d.ts +1 -1
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +3 -1
  183. package/dist/notifications/center.d.ts.map +1 -1
  184. package/dist/provider/index.d.ts +20 -1
  185. package/dist/provider/index.d.ts.map +1 -1
  186. package/dist/provider/index.js +30 -8
  187. package/dist/shell/app-brand.d.ts.map +1 -1
  188. package/dist/shell/app-brand.js +1 -0
  189. package/dist/shell/app-brand.module.css +2 -2
  190. package/dist/shell/app-header.module.css +18 -8
  191. package/dist/shell/app-main.module.css +54 -3
  192. package/dist/shell/app-main.module.css.d.ts +1 -0
  193. package/dist/shell/app-mobile-nav.module.css +16 -4
  194. package/dist/shell/app-sidebar.module.css +4 -6
  195. package/dist/shell/aside-docking.d.ts +31 -0
  196. package/dist/shell/aside-docking.d.ts.map +1 -0
  197. package/dist/shell/aside-docking.js +19 -0
  198. package/dist/shell/index.d.ts +1 -0
  199. package/dist/shell/index.d.ts.map +1 -1
  200. package/dist/shell/index.js +56 -16
  201. package/dist/shell/mobile-nav-model.d.ts +1 -1
  202. package/dist/shell/page-aside.d.ts +22 -3
  203. package/dist/shell/page-aside.d.ts.map +1 -1
  204. package/dist/shell/page-aside.js +27 -13
  205. package/dist/shell/page-bar.d.ts.map +1 -1
  206. package/dist/shell/page-bar.js +10 -5
  207. package/dist/shell/page-bar.module.css +10 -34
  208. package/dist/shell/use-breakpoint.d.ts +3 -4
  209. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  210. package/dist/shell/use-breakpoint.js +3 -25
  211. package/dist/shell/use-size-class.d.ts +6 -0
  212. package/dist/shell/use-size-class.d.ts.map +1 -0
  213. package/dist/shell/use-size-class.js +19 -0
  214. package/dist/state/persisted-or-local.d.ts +4 -1
  215. package/dist/state/persisted-or-local.d.ts.map +1 -1
  216. package/dist/state/persisted-or-local.js +4 -3
  217. package/dist/state/persisted.d.ts +10 -0
  218. package/dist/state/persisted.d.ts.map +1 -1
  219. package/dist/state/persisted.js +44 -14
  220. package/dist/styles.css +41 -0
  221. package/dist/surfaces.d.ts +4 -4
  222. package/dist/surfaces.d.ts.map +1 -1
  223. package/dist/surfaces.js +11 -3
  224. package/dist/theme/controls.module.css +7 -0
  225. package/dist/theme/ctl-theme.d.ts +1 -0
  226. package/dist/theme/ctl-theme.d.ts.map +1 -1
  227. package/dist/theme/ctl-theme.js +12 -4
  228. package/dist/theme/floating.module.css +37 -0
  229. package/dist/theme/floating.module.css.d.ts +3 -0
  230. package/dist/theme/index.d.ts +2 -2
  231. package/dist/theme/index.d.ts.map +1 -1
  232. package/dist/theme/index.js +34 -5
  233. package/dist/theme/nav-link.module.css +7 -0
  234. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  235. package/dist/tokens/index.d.ts +7 -16
  236. package/dist/tokens/index.d.ts.map +1 -1
  237. package/dist/tokens/index.js +23 -28
  238. package/dist/tokens/palette.d.ts +5 -27
  239. package/dist/tokens/palette.d.ts.map +1 -1
  240. package/dist/tokens/palette.js +8 -30
  241. package/dist/tokens/size-classes.d.ts +40 -0
  242. package/dist/tokens/size-classes.d.ts.map +1 -0
  243. package/dist/tokens/size-classes.js +29 -0
  244. package/dist/tokens.css +8 -4
  245. package/llms.txt +3 -3
  246. package/package.json +18 -17
  247. package/src/agent-chat/composer.tsx +74 -47
  248. package/src/agent-chat/thread-workspace.tsx +8 -11
  249. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  250. package/src/charts/cursor/touch-pin.ts +138 -0
  251. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  252. package/src/charts/hooks/use-hysteresis.ts +26 -0
  253. package/src/charts/hooks/useBandPlot.ts +90 -27
  254. package/src/charts/hooks/useChartCursor.ts +211 -26
  255. package/src/charts/index.ts +0 -8
  256. package/src/charts/kinds/BandStrip.tsx +25 -61
  257. package/src/charts/kinds/Donut.tsx +153 -62
  258. package/src/charts/kinds/DualPanel.tsx +21 -26
  259. package/src/charts/kinds/Heatmap.tsx +61 -22
  260. package/src/charts/kinds/MirroredBars.tsx +23 -65
  261. package/src/charts/kinds/band-chrome.tsx +91 -0
  262. package/src/charts/kinds/donut-layout.ts +82 -0
  263. package/src/charts/layout/auto-margin.ts +24 -5
  264. package/src/charts/layout/end-labels.ts +135 -0
  265. package/src/charts/primitives/Axes.tsx +112 -21
  266. package/src/charts/primitives/CartesianChart.tsx +282 -111
  267. package/src/charts/primitives/ChartCard.tsx +119 -48
  268. package/src/charts/primitives/ChartFrame.tsx +204 -60
  269. package/src/charts/primitives/ChartLegend.tsx +115 -78
  270. package/src/charts/primitives/ChartTooltip.tsx +24 -3
  271. package/src/charts/primitives/Crosshair.tsx +2 -2
  272. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  273. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  274. package/src/charts/primitives/HoverOverlay.tsx +62 -20
  275. package/src/charts/primitives/chart-card-context.ts +46 -0
  276. package/src/charts/primitives/chart-frame-layout.ts +207 -182
  277. package/src/charts/primitives/chart-layout.ts +318 -0
  278. package/src/charts/primitives/chart-tier.tsx +66 -21
  279. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  280. package/src/charts/series.ts +4 -1
  281. package/src/charts/utils/ticks.ts +95 -19
  282. package/src/common/errors.ts +25 -1
  283. package/src/common/hit-floor.ts +9 -0
  284. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  285. package/src/common/use-measured-widths.ts +88 -0
  286. package/src/common/use-media-query.ts +56 -0
  287. package/src/common/validate.ts +17 -4
  288. package/src/content/article-layout.module.css +45 -4
  289. package/src/content/article-layout.module.css.d.ts +3 -0
  290. package/src/content/article-layout.tsx +60 -5
  291. package/src/content/code-block.module.css +32 -2
  292. package/src/content/code-block.tsx +47 -3
  293. package/src/content/prose.module.css +13 -8
  294. package/src/controls/actions.module.css +14 -1
  295. package/src/controls/actions.module.css.d.ts +1 -0
  296. package/src/controls/actions.tsx +223 -75
  297. package/src/controls/controls.module.css +13 -31
  298. package/src/controls/enum-filter.tsx +2 -2
  299. package/src/controls/filter-context.tsx +11 -0
  300. package/src/controls/filter-pill.tsx +1 -0
  301. package/src/controls/index.ts +0 -1
  302. package/src/controls/multi-select-filter.tsx +2 -2
  303. package/src/controls/sync-button.module.css +2 -1
  304. package/src/dashboard/section.module.css +1 -26
  305. package/src/dashboard/section.tsx +1 -0
  306. package/src/dashboard/stat-card.module.css +6 -9
  307. package/src/dashboard/stat-card.tsx +6 -3
  308. package/src/dashboard/stat-group.module.css +33 -66
  309. package/src/dashboard/widget-grid.module.css +11 -38
  310. package/src/dashboard/widget-grid.tsx +7 -6
  311. package/src/dashboard/widget-header.module.css +145 -133
  312. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  313. package/src/dashboard/widget-header.tsx +45 -11
  314. package/src/data/data-table-state.ts +218 -0
  315. package/src/data/data-table.module.css +94 -0
  316. package/src/data/data-table.module.css.d.ts +11 -0
  317. package/src/data/data-table.tsx +547 -299
  318. package/src/data/virtual-list.tsx +31 -0
  319. package/src/forms/form-layout.module.css +20 -5
  320. package/src/forms/form-layout.module.css.d.ts +1 -0
  321. package/src/forms/form-layout.tsx +12 -10
  322. package/src/guard/index.ts +188 -3
  323. package/src/guard/types.ts +3 -2
  324. package/src/index.ts +2 -0
  325. package/src/notifications/center.tsx +3 -0
  326. package/src/provider/index.tsx +60 -7
  327. package/src/shell/app-brand.module.css +2 -2
  328. package/src/shell/app-brand.tsx +1 -0
  329. package/src/shell/app-header.module.css +18 -8
  330. package/src/shell/app-main.module.css +54 -3
  331. package/src/shell/app-main.module.css.d.ts +1 -0
  332. package/src/shell/app-mobile-nav.module.css +16 -4
  333. package/src/shell/app-sidebar.module.css +4 -6
  334. package/src/shell/aside-docking.ts +54 -0
  335. package/src/shell/index.tsx +178 -129
  336. package/src/shell/mobile-nav-model.ts +1 -1
  337. package/src/shell/page-aside.tsx +70 -21
  338. package/src/shell/page-bar.module.css +10 -34
  339. package/src/shell/page-bar.tsx +10 -5
  340. package/src/shell/use-breakpoint.ts +9 -45
  341. package/src/shell/use-size-class.ts +31 -0
  342. package/src/state/persisted-or-local.ts +10 -4
  343. package/src/state/persisted.ts +76 -17
  344. package/src/styles.css +41 -0
  345. package/src/surfaces.ts +9 -1
  346. package/src/theme/controls.module.css +7 -0
  347. package/src/theme/ctl-theme.tsx +15 -3
  348. package/src/theme/floating.module.css +37 -0
  349. package/src/theme/floating.module.css.d.ts +3 -0
  350. package/src/theme/index.ts +46 -8
  351. package/src/theme/nav-link.module.css +7 -0
  352. package/src/theme-lab/derive-controls.tsx +2 -0
  353. package/src/tokens/index.ts +20 -30
  354. package/src/tokens/palette.ts +6 -28
  355. package/src/tokens/size-classes.ts +46 -0
  356. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  357. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -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,14 +50,14 @@ import type {
48
50
  Column,
49
51
  ColumnDef,
50
52
  ColumnFiltersState,
51
- ColumnPinningState,
52
53
  FilterFn,
53
54
  PaginationState,
55
+ Row,
54
56
  RowData,
55
57
  RowSelectionState,
56
58
  SortingState,
57
59
  Table as TanstackTable,
58
- Updater,
60
+ VisibilityState,
59
61
  } from '@tanstack/react-table'
60
62
  import {
61
63
  flexRender,
@@ -65,16 +67,13 @@ import {
65
67
  getSortedRowModel,
66
68
  useReactTable,
67
69
  } from '@tanstack/react-table'
68
- import type {
69
- CSSProperties,
70
- KeyboardEvent as ReactKeyboardEvent,
71
- ReactNode,
72
- RefObject,
73
- } from 'react'
74
- import { useCallback, 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'
75
72
  import { cx } from '../common/props'
76
73
  import type { BasaltProps, SlotStylesProps } from '../common/props'
77
74
  import { BASALT_PREFIX } from '../common/errors'
75
+ import { HIT_COARSE } from '../common/hit-floor'
76
+ import { useMeasuredWidths } from '../common/use-measured-widths'
78
77
  import { assertRequiredProps, useValidateProps } from '../common/validate'
79
78
  import { FilterSet } from '../controls'
80
79
  import { ActionGroup, BarActionSlot } from '../controls/actions'
@@ -91,6 +90,13 @@ import type { EnumField, FieldHandle, MultiField } from '../state'
91
90
  import { WidgetHeader } from '../dashboard'
92
91
  import { ErrorState } from '../dashboard/query-state'
93
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'
94
100
  import { dataQueryBranch } from './query-branch'
95
101
  import { isDev } from '../common/is-dev'
96
102
  import classes from './data-table.module.css'
@@ -121,6 +127,14 @@ declare module '@tanstack/react-table' {
121
127
  * `<td>`'s mono font then overrides what the cell asked for.
122
128
  */
123
129
  numeral?: boolean
130
+ /**
131
+ * Fold priority for the measured column fold (`docs/CONTROLS-SPEC.md` §2): once the
132
+ * table no longer fits its wrapper, the HIGHEST-numbered priority column folds first, moving
133
+ * into a per-row disclosure instead of forcing the table into horizontal scroll. Unset columns
134
+ * fall back to their declared position, so a bare `<BasaltDataTable data columns />` folds its
135
+ * LAST column first — nothing regresses with no `meta.priority` set anywhere. See `foldOrder`.
136
+ */
137
+ priority?: number
124
138
  }
125
139
  }
126
140
 
@@ -315,32 +329,37 @@ const SELECT_COLUMN_ID = '__basalt_select__'
315
329
  * Built per render inside a `useMemo`, not hoisted as a constant: `ColumnDef` is generic in `T`, and
316
330
  * a single shared instance would have to be cast at every call site.
317
331
  *
318
- * `onClick`'s `stopPropagation` is the load-bearing line. The checkbox lives inside the row, and a
319
- * row carrying `onRowActivate` opens a detail from a click — so without it, ticking the box also
320
- * navigated away from the table the tick was meant to act on.
332
+ * The select cell's `stopPropagation` (see the `<Table.Td>`) is the load-bearing line. The checkbox
333
+ * lives inside the row, and a row carrying `onRowActivate` opens a detail from a click — so without
334
+ * it, ticking the box also navigated away from the table the tick was meant to act on.
321
335
  */
322
336
  function selectionColumn<T>(): ColumnDef<T, unknown> {
323
337
  return {
324
338
  id: SELECT_COLUMN_ID,
325
339
  enableSorting: false,
326
340
  header: ({ table }) => (
327
- <Checkbox
328
- size="xs"
329
- aria-label="Select all rows"
330
- checked={table.getIsAllPageRowsSelected()}
331
- indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
332
- onChange={table.getToggleAllPageRowsSelectedHandler()}
333
- />
341
+ // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
342
+ <label className={classes.selectHit}>
343
+ <Checkbox
344
+ size="xs"
345
+ aria-label="Select all rows"
346
+ checked={table.getIsAllPageRowsSelected()}
347
+ indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
348
+ onChange={table.getToggleAllPageRowsSelectedHandler()}
349
+ />
350
+ </label>
334
351
  ),
335
352
  cell: ({ row }) => (
336
- <Checkbox
337
- size="xs"
338
- aria-label={`Select row ${row.index + 1}`}
339
- checked={row.getIsSelected()}
340
- disabled={!row.getCanSelect()}
341
- onChange={row.getToggleSelectedHandler()}
342
- onClick={(event) => event.stopPropagation()}
343
- />
353
+ // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
354
+ <label className={classes.selectHit}>
355
+ <Checkbox
356
+ size="xs"
357
+ aria-label={`Select row ${row.index + 1}`}
358
+ checked={row.getIsSelected()}
359
+ disabled={!row.getCanSelect()}
360
+ onChange={row.getToggleSelectedHandler()}
361
+ />
362
+ </label>
344
363
  ),
345
364
  }
346
365
  }
@@ -370,8 +389,9 @@ function BulkActionBar({ count, actions }: { count: number; actions: BarAction[]
370
389
 
371
390
  /**
372
391
  * The four boxes `BasaltDataTable` paints: `root` is the outer fragment's replacement wrapper,
373
- * `toolbar` is the search/facets/actions row, `table` is the Mantine `Table` itself, `footer` is
374
- * 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.
375
395
  */
376
396
  export type BasaltDataTableSlot = 'root' | 'toolbar' | 'table' | 'footer'
377
397
 
@@ -577,6 +597,37 @@ export type BasaltDataTableProps<T> = BasaltProps &
577
597
  * <BasaltDataTable data={rows} columns={columns} onRowActivate={(row) => setSelected(row)} />
578
598
  */
579
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
580
631
  /**
581
632
  * Prepends a checkbox column (header = select-all on the page) and arms TanStack's row-selection
582
633
  * feature. Selection is uncontrolled unless `rowSelection` is passed.
@@ -777,6 +828,8 @@ function SortIndicator({ direction }: { direction: 'asc' | 'desc' | false }) {
777
828
  )
778
829
  }
779
830
 
831
+ const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const
832
+
780
833
  // ── Numeral cell idiom (docs/DESIGN-SPEC.md §3: "Stat/table numeral: mono, 12-12.5px, weight
781
834
  // 500, ink") — auto-detected from the cell's raw value (no column-level opt-in prop needed) so
782
835
  // every numeric column gets tabular, mono figures with zero call-site configuration.
@@ -884,6 +937,215 @@ function getPinnedCellStyle<T>(
884
937
  }
885
938
  }
886
939
 
940
+ // ── Column fold (`meta.priority`, docs/CONTROLS-SPEC.md §2) ──────────────────
941
+
942
+ /** The column id TanStack will resolve internally for this raw def — explicit `id`, else the
943
+ * accessor key string. Mirrors the resolution `facetColumns` already relies on above. */
944
+ function columnDefId<T>(column: ColumnDef<T, unknown>): string | undefined {
945
+ if (column.id !== undefined) return column.id
946
+ return 'accessorKey' in column && typeof column.accessorKey === 'string'
947
+ ? column.accessorKey
948
+ : undefined
949
+ }
950
+
951
+ /**
952
+ * Fold order for `meta.priority` — the FIRST id in the returned list folds first. Effective
953
+ * priority is `meta.priority ?? declaredIndex`, so with no `meta.priority` set anywhere it is just
954
+ * the column's position: the LAST declared column folds first, the zero-config default a bare
955
+ * `<BasaltDataTable data columns />` needs. An explicit priority folds a HIGHER number before a
956
+ * lower one; a tie folds in reverse declared order. A column with no resolvable id (an
957
+ * `accessorFn` with no explicit `id`) is never a fold candidate — nothing downstream could
958
+ * reliably re-identify it once hidden.
959
+ */
960
+ function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
961
+ return columns
962
+ .map((column, index) => ({
963
+ id: columnDefId(column),
964
+ priority: column.meta?.priority ?? index,
965
+ index,
966
+ }))
967
+ .filter((candidate): candidate is { id: string; priority: number; index: number } =>
968
+ Boolean(candidate.id),
969
+ )
970
+ .toSorted((a, b) => b.priority - a.priority || b.index - a.index)
971
+ .map((candidate) => candidate.id)
972
+ }
973
+
974
+ /** Marks a rendered data column header for `useColumnFold`'s width measurement — never read past
975
+ * that hook, so it carries no styling and needs no CSS-module counterpart. */
976
+ const FOLD_ID_ATTR = 'data-basalt-fold-id'
977
+
978
+ /**
979
+ * Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
980
+ * still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
981
+ * or `enableHiding: false` — which occupy room but can never fold) and the disclosure toggle's own
982
+ * width, once anything is folded — fit `room`. A candidate with no measured width yet (never
983
+ * rendered) is skipped rather than folded blind, since folding it would remove the one place its
984
+ * width could ever be read from.
985
+ */
986
+ export function planColumnFold(input: {
987
+ room: number
988
+ toggleWidth: number
989
+ overhead: number
990
+ order: readonly string[]
991
+ widths: ReadonlyMap<string, number>
992
+ }): ReadonlySet<string> {
993
+ const { room, toggleWidth, overhead, order, widths } = input
994
+ const known = order.filter((id) => widths.has(id))
995
+ let unfolded = overhead + known.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
996
+ const folded = new Set<string>()
997
+ for (const id of known) {
998
+ // Never fold the last remaining data column — a table with every column folded has no row
999
+ // identifier left to read at all, which is worse than the horizontal scroll this exists to
1000
+ // avoid. With no explicit `meta.priority` this is `order`'s LAST id, which `foldOrder`'s own
1001
+ // doc pins as the FIRST declared column (the last-declared one folds first).
1002
+ if (folded.size >= known.length - 1) break
1003
+ const withToggle = unfolded + (folded.size > 0 ? toggleWidth : 0)
1004
+ if (withToggle <= room) break
1005
+ folded.add(id)
1006
+ unfolded -= widths.get(id) ?? 0
1007
+ }
1008
+ return folded
1009
+ }
1010
+
1011
+ /**
1012
+ * The general-case measured fold. Reacting to `useMeasuredContainment`'s boolean fit directly
1013
+ * (fold one, remeasure, maybe unfold) would oscillate, because folding changes the very box being
1014
+ * measured. Instead this reads each candidate `<th data-basalt-fold-id>`'s width once, while it is
1015
+ * visible, caches it, and decides analytically (`planColumnFold`). `useMeasuredWidths` owns the
1016
+ * cache and the wrapper-vs-table observer that `controls/actions.tsx`'s fold shares.
1017
+ */
1018
+ function useColumnFold(input: {
1019
+ active: boolean
1020
+ order: readonly string[]
1021
+ /**
1022
+ * Columns excluded from fold candidacy altogether (pinned, or `enableHiding: false`) — they are
1023
+ * never in `order`, but they still occupy room, and their measured widths (carried in the same
1024
+ * `[data-basalt-fold-id]` sweep as every eligible column's) become fixed overhead against the
1025
+ * fold baseline. See `planColumnFold`'s `overhead` param.
1026
+ */
1027
+ fixedIds: readonly string[]
1028
+ }): {
1029
+ wrapperRef: RefObject<HTMLDivElement | null>
1030
+ folded: ReadonlySet<string>
1031
+ } {
1032
+ const { active, order, fixedIds } = input
1033
+ const wrapperRef = useRef<HTMLDivElement>(null)
1034
+ const [folded, setFolded] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
1035
+ const orderRef = useRef(order)
1036
+ orderRef.current = order
1037
+ const fixedIdsRef = useRef(fixedIds)
1038
+ fixedIdsRef.current = fixedIds
1039
+ useLayoutEffect(() => {
1040
+ if (!active || order.length === 0) {
1041
+ setFolded((current) => (current.size === 0 ? current : EMPTY_FOLD_SET))
1042
+ }
1043
+ }, [active, order.length])
1044
+
1045
+ useMeasuredWidths({
1046
+ resolveRoot: () => wrapperRef.current,
1047
+ signature: `${order.join('|')}::${fixedIds.join('|')}`,
1048
+ enabled: active && order.length > 0,
1049
+ // The table too, not only the wrapper: a re-measured column set moves its min-content width
1050
+ // while the wrapper's own box never changes.
1051
+ extra: (root) => [root.querySelector('table')],
1052
+ onMeasure: (root, boxes) => {
1053
+ for (const cell of root.querySelectorAll<HTMLElement>(`[${FOLD_ID_ATTR}]`)) {
1054
+ const id = cell.getAttribute(FOLD_ID_ATTR)
1055
+ if (id !== null && cell.offsetWidth > 0) {
1056
+ boxes.current.set(id, { width: cell.offsetWidth, height: cell.offsetHeight })
1057
+ }
1058
+ }
1059
+ const room = root.clientWidth
1060
+ // A zero reading is an un-laid-out ancestor, not a table with no room — the same guard
1061
+ // `useMeasuredContainment` above runs against the aside animating in from 0.
1062
+ if (room === 0) return
1063
+ const widths = new Map([...boxes.current].map(([id, box]) => [id, box.width]))
1064
+ const overhead = fixedIdsRef.current.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
1065
+ const next = planColumnFold({
1066
+ room,
1067
+ // The disclosure toggle is a fixed icon-button cell matched to the coarse hit floor, so it
1068
+ // reads as one more `--vx-hit` control rather than a narrower afterthought.
1069
+ toggleWidth: HIT_COARSE,
1070
+ overhead,
1071
+ order: orderRef.current,
1072
+ widths,
1073
+ })
1074
+ setFolded((current) =>
1075
+ current.size === next.size && [...current].every((id) => next.has(id)) ? current : next,
1076
+ )
1077
+ },
1078
+ })
1079
+
1080
+ return { wrapperRef, folded: active ? folded : EMPTY_FOLD_SET }
1081
+ }
1082
+
1083
+ /** The label half of a folded column's disclosure row — its own header when it is a plain string
1084
+ * (the common case), else the column id, since a function/`ReactNode` header has no header
1085
+ * CONTEXT to render with here (this is a body row, not the `<thead>`). */
1086
+ function columnLabel<T>(column: Column<T, unknown>): string {
1087
+ const header = column.columnDef.header
1088
+ return typeof header === 'string' ? header : column.id
1089
+ }
1090
+
1091
+ /** One row's folded columns as label/value pairs, directly under it. `row.getAllCells()` returns
1092
+ * every leaf cell regardless of visibility (unlike `getVisibleCells()`), so a folded column's own
1093
+ * `cell` renderer still draws here exactly as it would in the `<td>` it no longer occupies. */
1094
+ function RowDisclosure<T>({
1095
+ row,
1096
+ folded,
1097
+ }: {
1098
+ row: Row<T>
1099
+ folded: ReadonlySet<string>
1100
+ }): ReactNode {
1101
+ const cells = row.getAllCells().filter((cell) => folded.has(cell.column.id))
1102
+ return (
1103
+ <div className={classes.foldDisclosure}>
1104
+ {cells.map((cell) => (
1105
+ <div key={cell.id} className={classes.foldDisclosureRow}>
1106
+ <Text className={classes.foldDisclosureLabel}>{columnLabel(cell.column)}</Text>
1107
+ <div className={classes.foldDisclosureValue}>
1108
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1109
+ </div>
1110
+ </div>
1111
+ ))}
1112
+ </div>
1113
+ )
1114
+ }
1115
+
1116
+ /** The per-row expand/collapse control the fold-disclosure column renders — a text glyph, since
1117
+ * basalt ships no icon set (`docs/CONTROLS-SPEC.md` §2, mirrors `SortIndicator` above). */
1118
+ function FoldToggle({
1119
+ expanded,
1120
+ controlsId,
1121
+ onToggle,
1122
+ }: {
1123
+ expanded: boolean
1124
+ controlsId: string
1125
+ onToggle: () => void
1126
+ }): ReactNode {
1127
+ return (
1128
+ <button
1129
+ type="button"
1130
+ className={classes.foldToggle}
1131
+ data-basalt-hit
1132
+ aria-expanded={expanded}
1133
+ aria-controls={controlsId}
1134
+ aria-label={expanded ? 'Hide row details' : 'Show row details'}
1135
+ // A row carrying `onRowActivate` reads a bubbled click as "activate the row" — the toggle
1136
+ // would open/close its disclosure AND fire the row's own action underneath it.
1137
+ onClick={(event) => {
1138
+ event.stopPropagation()
1139
+ onToggle()
1140
+ }}
1141
+ >
1142
+ <Box component="span" aria-hidden>
1143
+ {expanded ? '▾' : '▸'}
1144
+ </Box>
1145
+ </button>
1146
+ )
1147
+ }
1148
+
887
1149
  // ── The manual-pagination contract ────────────────────────────────────────────
888
1150
 
889
1151
  /** One client-side control that `manualPagination` turns into a claim the table cannot support. */
@@ -973,38 +1235,20 @@ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): vo
973
1235
  * statically: `stickyHeader` with neither `maxHeight` nor `minWidth`.
974
1236
  *
975
1237
  * Every other table renders inside `Table.ScrollContainer type="native"`, because a bare `<table>`
976
- * sizes to its own min-content and a five-column one measured 448px inside a 390px viewport,
977
- * dragging the whole page sideways (`tests/layout/no-horizontal-overflow.layout.test.ts`). This
978
- * shape cannot take that container unconditionally: an `overflow-x: auto` box computes `overflow-y`
979
- * to `auto` as well, which makes it the sticky header's own scrollport — and with no height cap
980
- * that box has no scroll range at all, so the `<thead>` scrolls clean away (MEASURED in Chrome:
981
- * top 10.5 → −389.5 across a 400px scroll). Containing it unconditionally deletes the feature;
982
- * leaving it bare unconditionally widens the page.
1238
+ * sizes to its own min-content and would drag the page sideways. This shape cannot take that
1239
+ * container unconditionally: an `overflow-x: auto` box also computes `overflow-y`, making it the
1240
+ * sticky header's own scrollport with no scroll range, so the `<thead>` scrolls clean away.
983
1241
  *
984
- * So the wrapper's overflow is decided by MEASUREMENT, the same fit-check `useTrackFits`
985
- * (`controls/panel-row.tsx`) runs for a segmented track. While the table FITS its wrapper the
986
- * wrapper is `overflow: visible` — bare, and the page-sticky header sticks against `AppShell.Main`
987
- * exactly as it did before. Once the table is WIDER the wrapper flips to `overflow-x: auto`, the
988
- * columns stay reachable by horizontal scroll, and the header is necessarily inert at that width.
989
- * That is the honest trade: a header that stops sticking is recoverable, a page that scrolls
990
- * sideways takes every fixed and sticky element on it with it.
1242
+ * So the wrapper's overflow is MEASURED. While the table FITS, the wrapper is bare and the sticky
1243
+ * header sticks; once it is WIDER, the wrapper flips to `overflow-x: auto` and the header is inert.
1244
+ * The trade is deliberate: a header that stops sticking is recoverable, a page that scrolls
1245
+ * sideways is not.
991
1246
  *
992
- * Three properties that are easy to lose:
993
- *
994
- * - **The observer stays alive in BOTH states** — no one-way latch, unlike `useTrackFits`. The
995
- * wrapper must revert to bare when the space comes back (the window widened, the sidebar
996
- * collapsed, the aside closed), and a contained table's `offsetWidth` is still its min-content
997
- * width, so the same comparison keeps answering correctly from inside the contained state.
998
- * - **Equality counts as FITS**, which is what stops the two states oscillating: a contained
999
- * wrapper that grows past the table reads `offsetWidth === clientWidth` (the `width: 100%` table
1000
- * resolves against the container) and flips back exactly once.
1001
- * - **A zero `clientWidth` is `unknown`, not overflow** — the ancestor chain has not been laid out
1002
- * yet (the aside animates its width in from 0, `docs/ASIDE-SPEC.md` §0). Committing to contained
1003
- * on that reading would wrap a table that was never too wide.
1004
- *
1005
- * `useLayoutEffect` runs before paint, so a table that is already measurable resolves without a
1006
- * flash of the wrong mode. SSR reaches neither the effect nor a `window`, and the default state is
1007
- * bare — the same node the server and the first client paint both render.
1247
+ * Three invariants: the observer stays alive in BOTH states (no one-way latch, so the wrapper
1248
+ * reverts when the space returns); equality counts as FITS (a `width: 100%` table reads
1249
+ * `offsetWidth === clientWidth`, which stops oscillation); and a zero `clientWidth` is `unknown`,
1250
+ * not overflow (the aside animates its width in from 0). `useLayoutEffect` runs before paint, so an
1251
+ * already-measurable table resolves without a flash; the default is bare for SSR and first paint.
1008
1252
  */
1009
1253
  function useMeasuredContainment(active: boolean): {
1010
1254
  wrapperRef: RefObject<HTMLDivElement | null>
@@ -1013,50 +1257,26 @@ function useMeasuredContainment(active: boolean): {
1013
1257
  const wrapperRef = useRef<HTMLDivElement>(null)
1014
1258
  const [contained, setContained] = useState(false)
1015
1259
 
1016
- useLayoutEffect(() => {
1017
- if (!active) return
1018
- const wrapper = wrapperRef.current
1019
- if (wrapper === null) return
1020
-
1021
- // Three answers, not two — see the docblock. `unknown` moves neither the state nor the
1022
- // observer; it is simply not an answer yet.
1023
- const checkFit = (): 'fits' | 'contained' | 'unknown' => {
1024
- const table = wrapper.querySelector('table')
1025
- if (table === null) return 'unknown'
1026
- if (wrapper.clientWidth === 0 || table.offsetWidth === 0) return 'unknown'
1027
- return table.offsetWidth <= wrapper.clientWidth ? 'fits' : 'contained'
1028
- }
1029
-
1030
- const apply = (): void => {
1031
- const result = checkFit()
1032
- if (result === 'unknown') return
1033
- const next = result === 'contained'
1034
- setContained((current) => (current === next ? current : next))
1035
- }
1036
-
1037
- if (typeof ResizeObserver === 'undefined') {
1038
- // No recovery path without an observer, so the one same-tick read is this environment's
1039
- // only chance. `unknown` stays bare, which is the pre-measurement default anyway.
1040
- apply()
1041
- return
1042
- }
1043
-
1044
- const observer = new ResizeObserver(apply)
1045
- observer.observe(wrapper)
1046
- const table = wrapper.querySelector('table')
1260
+ // The same measured-fold core `useColumnFold` uses, so this shape no longer mounts a second
1261
+ // bespoke `ResizeObserver` on the wrapper + table.
1262
+ useMeasuredWidths({
1263
+ resolveRoot: () => wrapperRef.current,
1264
+ signature: String(active),
1265
+ enabled: active,
1047
1266
  // The TABLE too, not only the wrapper: a column set that re-measures (a facet filter changing
1048
1267
  // the widest cell, a font settling) moves the table's min-content width while the wrapper's
1049
1268
  // box never changes at all.
1050
- if (table !== null) observer.observe(table)
1051
-
1052
- // Same check once, synchronously, so an already-settled table resolves before paint rather
1053
- // than waiting for the observer's first async tick.
1054
- apply()
1055
-
1056
- return () => {
1057
- observer.disconnect()
1058
- }
1059
- }, [active])
1269
+ extra: (root) => [root.querySelector('table')],
1270
+ onMeasure: (root) => {
1271
+ const table = root.querySelector('table')
1272
+ // Three answers, not two — see the docblock. A zero reading is an un-laid-out ancestor
1273
+ // (the aside animating in from 0), not overflow, so it moves neither the state nor the
1274
+ // observer; equality counts as FITS and stops the fit/contained oscillation.
1275
+ if (table === null || root.clientWidth === 0 || table.offsetWidth === 0) return
1276
+ const next = table.offsetWidth > root.clientWidth
1277
+ setContained((current) => (current === next ? current : next))
1278
+ },
1279
+ })
1060
1280
 
1061
1281
  return { wrapperRef, contained: active && contained }
1062
1282
  }
@@ -1107,32 +1327,23 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1107
1327
  isLoading = false,
1108
1328
  query,
1109
1329
  skeletonRows = 5,
1110
- initialSorting,
1111
- onSortingChange,
1112
1330
  enableGlobalFilter = false,
1113
1331
  globalFilterPlaceholder = 'Search…',
1114
1332
  searchIcon,
1115
- initialGlobalFilter,
1116
- onGlobalFilterChange,
1117
1333
  facets,
1118
- onColumnFiltersChange,
1119
1334
  manualFiltering = false,
1120
1335
  actions,
1121
1336
  onRowActivate,
1337
+ renderCard,
1122
1338
  enableRowSelection = false,
1123
- rowSelection,
1124
- onRowSelectionChange,
1125
1339
  getRowId,
1126
1340
  bulkActions,
1127
1341
  enablePagination = false,
1128
1342
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
1129
- initialPagination,
1130
- onPaginationChange,
1131
1343
  manualPagination = false,
1132
1344
  rowCount,
1133
1345
  pageCount,
1134
1346
  enablePinning = false,
1135
- initialColumnPinning,
1136
1347
  maxHeight,
1137
1348
  minWidth,
1138
1349
  stickyHeader,
@@ -1145,17 +1356,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1145
1356
  style,
1146
1357
  classNames,
1147
1358
  } = props
1148
- const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
1149
- const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
1150
- const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
1151
- const [pagination, setPagination] = useState<PaginationState>(
1152
- initialPagination ?? { pageIndex: 0, pageSize: pageSizeOptions[0] ?? 10 },
1153
- )
1154
- const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(initialColumnPinning ?? {})
1155
- // Uncontrolled by default; `rowSelection`, when passed, is the truth and this only mirrors it so
1156
- // the updater below has a base to apply against.
1157
- const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
1158
- const rowSelectionState = rowSelection ?? internalRowSelection
1359
+ const tableState = useDataTableState(props, pageSizeOptions[0] ?? 10)
1360
+ const { globalFilter, columnPinning, onGlobalFilterChange: handleGlobalFilterChange } = tableState
1159
1361
 
1160
1362
  // `stickyHeaderOffset` is DROPPED whenever the table owns a scroll container — see
1161
1363
  // `resolvedStickyHeaderOffset` below for why it is always wrong there. Dropping it silently is
@@ -1198,69 +1400,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1198
1400
  ],
1199
1401
  )
1200
1402
 
1201
- const handleSortingChange = useCallback(
1202
- (updater: Updater<SortingState>) => {
1203
- setSorting((prev) => {
1204
- const next = typeof updater === 'function' ? updater(prev) : updater
1205
- onSortingChange?.(next)
1206
- return next
1207
- })
1208
- },
1209
- [onSortingChange],
1210
- )
1211
-
1212
- const handleGlobalFilterChange = useCallback(
1213
- (updater: Updater<string>) => {
1214
- setGlobalFilter((prev) => {
1215
- const next = typeof updater === 'function' ? updater(prev) : updater
1216
- onGlobalFilterChange?.(next)
1217
- return next
1218
- })
1219
- },
1220
- [onGlobalFilterChange],
1221
- )
1222
-
1223
- const handleColumnFiltersChange = useCallback(
1224
- (updater: Updater<ColumnFiltersState>) => {
1225
- setColumnFilters((prev) => {
1226
- const next = typeof updater === 'function' ? updater(prev) : updater
1227
- onColumnFiltersChange?.(next)
1228
- return next
1229
- })
1230
- },
1231
- [onColumnFiltersChange],
1232
- )
1233
-
1234
- const handlePaginationChange = useCallback(
1235
- (updater: Updater<PaginationState>) => {
1236
- setPagination((prev) => {
1237
- const next = typeof updater === 'function' ? updater(prev) : updater
1238
- onPaginationChange?.(next)
1239
- return next
1240
- })
1241
- },
1242
- [onPaginationChange],
1243
- )
1244
-
1245
- const handleRowSelectionChange = useCallback(
1246
- (updater: Updater<RowSelectionState>) => {
1247
- // CONTROLLED: the caller's map is the base AND the only writer. Mirroring it into state too
1248
- // would leave a copy that the caller never moves, and that stale copy becomes the selection
1249
- // the moment `rowSelection` goes back to `undefined` — plus every tick would render twice for
1250
- // one change. Report the next map and let the caller own it.
1251
- if (rowSelection !== undefined) {
1252
- onRowSelectionChange?.(typeof updater === 'function' ? updater(rowSelection) : updater)
1253
- return
1254
- }
1255
- setInternalRowSelection((prev) => {
1256
- const next = typeof updater === 'function' ? updater(prev) : updater
1257
- onRowSelectionChange?.(next)
1258
- return next
1259
- })
1260
- },
1261
- [rowSelection, onRowSelectionChange],
1262
- )
1263
-
1264
1403
  // Facets are TanStack `columnFilters` entries under the hood — inject an exact-match (or
1265
1404
  // any-of, for `multiple`) filterFn onto the matching column so the built-in `includesString`
1266
1405
  // auto-filter (substring match) never misfires against a Select's exact option value.
@@ -1268,7 +1407,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1268
1407
  if (!facets || facets.length === 0) return columns
1269
1408
  const facetById = new Map(facets.map((facet) => [facet.columnId, facet]))
1270
1409
  return columns.map((column) => {
1271
- const id = column.id ?? (column as { accessorKey?: string }).accessorKey
1410
+ const id = columnDefId(column)
1272
1411
  const facet = id === undefined ? undefined : facetById.get(id)
1273
1412
  if (!facet) return column
1274
1413
  const filterFn: FilterFn<T> = facet.multiple
@@ -1318,25 +1457,74 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1318
1457
  const filteringEnabled = !breaches.includes('filtering')
1319
1458
  const totalIsAuthoritative = !breaches.includes('total')
1320
1459
 
1460
+ // A capped body or a horizontal floor both need the same native scroll node — computed here,
1461
+ // ahead of the table, because `pageStickyHeader` (further down) needs it: `maxHeight` alone still
1462
+ // wants its own `ScrollArea` scrollport (a sticky `<thead>` resolves against it, not the measured
1463
+ // containment div, which offers no vertical scroll box at all), so EITHER prop rules out the
1464
+ // measured-containment shape.
1465
+ const scrolls = maxHeight !== undefined || minWidth !== undefined
1466
+
1467
+ // Fold eligibility is narrower than `scrolls`: only `minWidth` is a genuinely DECLARED horizontal
1468
+ // floor — the caller is stating "this table is at least this wide," which folding columns out of
1469
+ // would contradict. `maxHeight` alone is a purely VERTICAL cap and orthogonal to column width, so
1470
+ // a `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend for a
1471
+ // capped, sticky-header body) still folds instead of silently regressing to horizontal scroll.
1472
+ const foldEligible = minWidth === undefined
1473
+
1474
+ // The fold order is computed off the caller's OWN `columns` — never `tableColumns` — so the
1475
+ // selection checkbox (prepended below) is never a fold candidate; see `foldOrder`'s docblock for
1476
+ // the declared-order default and the explicit-priority rule. `enableHiding: false` and a PINNED
1477
+ // column are excluded outright, not merely de-prioritized — both are the caller stating this
1478
+ // column must stay visible, the same declaration a fold candidate has no way to override.
1479
+ const { columnFoldOrder, columnFoldFixedIds } = useMemo(() => {
1480
+ const pinnedIds = new Set([...(columnPinning.left ?? []), ...(columnPinning.right ?? [])])
1481
+ const excluded = new Set(
1482
+ columns.flatMap((column) => {
1483
+ const id = columnDefId(column)
1484
+ if (id === undefined) return []
1485
+ return column.enableHiding === false || pinnedIds.has(id) ? [id] : []
1486
+ }),
1487
+ )
1488
+ return {
1489
+ columnFoldOrder: foldOrder(columns).filter((id) => !excluded.has(id)),
1490
+ columnFoldFixedIds: [...excluded],
1491
+ }
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)
1496
+ const columnFold = useColumnFold({
1497
+ active: foldEligible && !projection.cards,
1498
+ order: columnFoldOrder,
1499
+ fixedIds: columnFoldFixedIds,
1500
+ })
1501
+ const columnVisibility: VisibilityState = useMemo(
1502
+ () => Object.fromEntries([...columnFold.folded].map((id) => [id, false])),
1503
+ [columnFold.folded],
1504
+ )
1505
+ const hasFolded = columnFold.folded.size > 0
1506
+ const foldRows = useRowDisclosure({ data, getRowId })
1507
+
1321
1508
  const table = useReactTable<T>({
1322
1509
  data,
1323
1510
  columns: tableColumns,
1324
1511
  state: {
1325
- sorting,
1512
+ sorting: tableState.sorting,
1326
1513
  globalFilter,
1327
- columnFilters,
1514
+ columnFilters: tableState.columnFilters,
1328
1515
  columnPinning,
1329
- ...(enablePagination && { pagination }),
1330
- ...(enableRowSelection && { rowSelection: rowSelectionState }),
1516
+ columnVisibility,
1517
+ ...(enablePagination && { pagination: tableState.pagination }),
1518
+ ...(enableRowSelection && { rowSelection: tableState.rowSelection }),
1331
1519
  },
1332
- onSortingChange: handleSortingChange,
1520
+ onSortingChange: tableState.onSortingChange,
1333
1521
  onGlobalFilterChange: handleGlobalFilterChange,
1334
- onColumnFiltersChange: handleColumnFiltersChange,
1335
- onColumnPinningChange: setColumnPinning,
1336
- ...(enablePagination && { onPaginationChange: handlePaginationChange }),
1522
+ onColumnFiltersChange: tableState.onColumnFiltersChange,
1523
+ onColumnPinningChange: tableState.onColumnPinningChange,
1524
+ ...(enablePagination && { onPaginationChange: tableState.onPaginationChange }),
1337
1525
  ...(enableRowSelection && {
1338
1526
  enableRowSelection: true,
1339
- onRowSelectionChange: handleRowSelectionChange,
1527
+ onRowSelectionChange: tableState.onRowSelectionChange,
1340
1528
  }),
1341
1529
  ...(getRowId !== undefined && { getRowId }),
1342
1530
  getCoreRowModel: getCoreRowModel(),
@@ -1371,10 +1559,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1371
1559
  const showSkeleton = branch === undefined ? isLoading : branch === 'pending'
1372
1560
  const queryError = branch === 'error' && query !== undefined ? query : undefined
1373
1561
 
1374
- // Every colSpan and every skeleton row counts the RENDERED columns, which is one more than
1375
- // `columns` once the checkbox column is prepended — a stale count left the empty state and the
1376
- // error row one cell short of the header.
1377
- const columnCount = columns.length + (enableRowSelection ? 1 : 0)
1562
+ // Every colSpan and every skeleton row counts the RENDERED columns: one more than `columns` for
1563
+ // the prepended checkbox column, one more again for the fold-disclosure toggle once anything has
1564
+ // folded — a stale count left the empty state and the error row short of the header.
1565
+ const columnCount =
1566
+ columns.length + (enableRowSelection ? 1 : 0) + (hasFolded ? 1 : 0) - columnFold.folded.size
1378
1567
 
1379
1568
  // `getSelectedRowModel()` is TanStack's own memo, so its `rows` array keeps its identity until
1380
1569
  // the selection or the row model actually moves — which is what makes both memos below hold.
@@ -1398,10 +1587,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1398
1587
  // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
1399
1588
  const rows = table.getRowModel().rows
1400
1589
 
1401
- // A capped body or a horizontal floor both need the same native scroll node. Everything else —
1402
- // pinning included — either takes that same container (the uncapped, non-sticky default) or the
1403
- // measured wrapper below; there is no third overflow box.
1404
- const scrolls = maxHeight !== undefined || minWidth !== undefined
1405
1590
  // `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
1406
1591
  // scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
1407
1592
  // always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
@@ -1420,9 +1605,27 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1420
1605
  // `useMeasuredContainment` drives instead: bare while the table fits, `overflow-x: auto` once it
1421
1606
  // does not. Its docblock carries the full accounting.
1422
1607
  const pageStickyHeader = !scrolls && stickyHeader === true
1423
- const containment = useMeasuredContainment(pageStickyHeader)
1608
+ const containment = useMeasuredContainment(pageStickyHeader && !projection.cards)
1424
1609
  const resolvedStickyHeaderOffset = scrolls ? undefined : stickyHeaderOffset
1425
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
+
1426
1629
  const tableNode = (
1427
1630
  <Table
1428
1631
  className={cx(classNames?.table)}
@@ -1440,6 +1643,9 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1440
1643
  <Table.Thead>
1441
1644
  {headerGroups.map((headerGroup) => (
1442
1645
  <Table.Tr key={headerGroup.id}>
1646
+ {/* The disclosure toggle column has no header of its own — nothing is "sorted" or
1647
+ "selected all" across it, only revealed per row. */}
1648
+ {hasFolded && <Table.Th className={classes.foldToggleCell} aria-hidden />}
1443
1649
  {headerGroup.headers.map((header) => {
1444
1650
  const canSort = sortingEnabled && header.column.getCanSort()
1445
1651
  const sorted = header.column.getIsSorted()
@@ -1448,9 +1654,14 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1448
1654
  ? getPinnedCellStyle(table, header.column)
1449
1655
  : undefined
1450
1656
  const align = resolveAlign(header.column)
1657
+ const isSelectColumn = header.column.id === SELECT_COLUMN_ID
1451
1658
  return (
1452
1659
  <Table.Th
1453
1660
  key={header.id}
1661
+ {...(isSelectColumn && { className: classes.selectCell })}
1662
+ // The measured fold's own read (`useColumnFold`) — never present on the
1663
+ // selection column, which is never a fold candidate.
1664
+ {...(!isSelectColumn && { [FOLD_ID_ATTR]: header.column.id })}
1454
1665
  onClick={canSort ? toggleSorting : undefined}
1455
1666
  onKeyDown={
1456
1667
  canSort
@@ -1462,15 +1673,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1462
1673
  : undefined
1463
1674
  }
1464
1675
  tabIndex={canSort ? 0 : undefined}
1465
- aria-sort={
1466
- canSort
1467
- ? sorted === 'asc'
1468
- ? 'ascending'
1469
- : sorted === 'desc'
1470
- ? 'descending'
1471
- : 'none'
1472
- : undefined
1473
- }
1676
+ aria-sort={canSort ? (sorted ? ARIA_SORT[sorted] : 'none') : undefined}
1474
1677
  style={{
1475
1678
  ...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
1476
1679
  ...(align !== undefined && { textAlign: align }),
@@ -1489,19 +1692,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1489
1692
  </Table.Thead>
1490
1693
  <Table.Tbody>
1491
1694
  {queryError !== undefined ? (
1492
- // The branch the table did not have (components audit #3). It spans every rendered
1493
- // column and carries the query's OWN `refetch` behind Retry, so the failure is reported
1494
- // where the rows would have been — not as an empty table saying nothing failed.
1495
1695
  <Table.Tr>
1496
- <Table.Td colSpan={columnCount}>
1497
- <ErrorState
1498
- error={queryError.error}
1499
- title="Could not load"
1500
- tier="section"
1501
- retrying={queryError.fetchStatus === 'fetching'}
1502
- onRetry={() => void queryError.refetch()}
1503
- />
1504
- </Table.Td>
1696
+ <Table.Td colSpan={columnCount}>{errorNode}</Table.Td>
1505
1697
  </Table.Tr>
1506
1698
  ) : showSkeleton ? (
1507
1699
  Array.from({ length: skeletonRows }, (_, rowIndex) => (
@@ -1515,13 +1707,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1515
1707
  ))
1516
1708
  ) : rows.length === 0 ? (
1517
1709
  <Table.Tr>
1518
- <Table.Td colSpan={columnCount}>
1519
- {emptyState ?? (
1520
- <Text c="dimmed" ta="center" size="sm" py="sm">
1521
- No data to display.
1522
- </Text>
1523
- )}
1524
- </Table.Td>
1710
+ <Table.Td colSpan={columnCount}>{emptyNode}</Table.Td>
1525
1711
  </Table.Tr>
1526
1712
  ) : (
1527
1713
  rows.map((row) => {
@@ -1532,51 +1718,60 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1532
1718
  ...row.getRightVisibleCells(),
1533
1719
  ]
1534
1720
  : row.getVisibleCells()
1721
+ const rowExpanded = hasFolded && foldRows.expanded.has(row.id)
1535
1722
  return (
1536
- <Table.Tr
1537
- key={row.id}
1538
- {...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
1539
- {...(onRowActivate !== undefined && {
1540
- className: classes.activatable,
1541
- 'data-activatable': true,
1542
- tabIndex: 0,
1543
- onClick: () => onRowActivate(row.original),
1544
- // Enter only. Space is the browser's own page-scroll on a focused non-button, and
1545
- // stealing it from a keyboard reader moving down a long table costs more than the
1546
- // second activation key buys.
1547
- onKeyDown: (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
1548
- if (event.key !== 'Enter') return
1549
- // Only the ROW's own Enter. A cell may hold a button, a link or the selection
1550
- // checkbox, and keydown bubbles — so without this an Enter on a nested control
1551
- // fired that control AND opened the row's detail behind it.
1552
- if (event.target !== event.currentTarget) return
1553
- event.preventDefault()
1554
- onRowActivate(row.original)
1555
- },
1556
- })}
1557
- >
1558
- {cells.map((cell) => {
1559
- const pinnedStyle = enablePinning
1560
- ? getPinnedCellStyle(table, cell.column)
1561
- : undefined
1562
- const align = resolveAlign(cell.column)
1563
- return (
1564
- <Table.Td
1565
- key={cell.id}
1566
- style={{
1567
- ...(typeof cell.getValue() === 'number' &&
1568
- cell.column.columnDef.meta?.numeral !== false
1569
- ? NUMERIC_CELL_STYLE
1570
- : undefined),
1571
- ...(align !== undefined && { textAlign: align }),
1572
- ...pinnedStyle,
1573
- }}
1574
- >
1575
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
1723
+ <Fragment key={row.id}>
1724
+ <Table.Tr
1725
+ {...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
1726
+ {...activationProps(onRowActivate, row.original)}
1727
+ >
1728
+ {hasFolded && (
1729
+ <Table.Td className={classes.foldToggleCell}>
1730
+ <FoldToggle
1731
+ expanded={rowExpanded}
1732
+ controlsId={`${row.id}-fold`}
1733
+ onToggle={() => foldRows.toggle(row.id)}
1734
+ />
1735
+ </Table.Td>
1736
+ )}
1737
+ {cells.map((cell) => {
1738
+ const pinnedStyle = enablePinning
1739
+ ? getPinnedCellStyle(table, cell.column)
1740
+ : undefined
1741
+ const align = resolveAlign(cell.column)
1742
+ return (
1743
+ <Table.Td
1744
+ key={cell.id}
1745
+ {...(cell.column.id === SELECT_COLUMN_ID && {
1746
+ className: classes.selectCell,
1747
+ // The label's ::after fills the cell, so a tap on it bubbles to the row —
1748
+ // stop it only where a tick is possible; a disabled row's tap is a row tap.
1749
+ ...(cell.row.getCanSelect() && {
1750
+ onClick: (event: ReactMouseEvent) => event.stopPropagation(),
1751
+ }),
1752
+ })}
1753
+ style={{
1754
+ ...(typeof cell.getValue() === 'number' &&
1755
+ cell.column.columnDef.meta?.numeral !== false
1756
+ ? NUMERIC_CELL_STYLE
1757
+ : undefined),
1758
+ ...(align !== undefined && { textAlign: align }),
1759
+ ...pinnedStyle,
1760
+ }}
1761
+ >
1762
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1763
+ </Table.Td>
1764
+ )
1765
+ })}
1766
+ </Table.Tr>
1767
+ {rowExpanded && (
1768
+ <Table.Tr id={`${row.id}-fold`} className={classes.foldDisclosureRowTr}>
1769
+ <Table.Td colSpan={columnCount} className={classes.foldDisclosureCell}>
1770
+ <RowDisclosure row={row} folded={columnFold.folded} />
1576
1771
  </Table.Td>
1577
- )
1578
- })}
1579
- </Table.Tr>
1772
+ </Table.Tr>
1773
+ )}
1774
+ </Fragment>
1580
1775
  )
1581
1776
  })
1582
1777
  )}
@@ -1584,6 +1779,25 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1584
1779
  </Table>
1585
1780
  )
1586
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
+
1587
1801
  const paginationState = table.getState().pagination
1588
1802
  // The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
1589
1803
  // Select renders EMPTY when its `value` matches no row, so a table opened at
@@ -1597,7 +1811,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1597
1811
  const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
1598
1812
 
1599
1813
  return (
1600
- <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
+ >
1601
1819
  {/*
1602
1820
  * ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
1603
1821
  * the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
@@ -1671,25 +1889,47 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1671
1889
  {bulkBarActions !== undefined && (
1672
1890
  <BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
1673
1891
  )}
1674
- {pageStickyHeader ? (
1675
- // Pinning takes this wrapper too — a pinned column's offsets are `position: sticky` on the
1676
- // cells and need no overflow box of their own, so the `overflow-x: auto` Box that used to
1677
- // sit here was the exact inert-sticky defect this shape exists to avoid.
1678
- <div
1679
- ref={containment.wrapperRef}
1680
- className={classes.containment}
1681
- data-contained={containment.contained ? 'true' : 'false'}
1682
- >
1683
- {tableNode}
1684
- </div>
1685
- ) : (
1686
- <Table.ScrollContainer
1687
- type="native"
1688
- minWidth={minWidth ?? 0}
1689
- {...(maxHeight !== undefined && { maxHeight })}
1690
- >
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 ? (
1901
+ // An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
1902
+ // never engages here (see `foldEligible`, above), so this shape keeps its exact
1903
+ // pre-existing DOM: no extra measuring wrapper for a table nothing folds.
1904
+ <Table.ScrollContainer type="native" minWidth={minWidth ?? 0} maxHeight={maxHeight}>
1691
1905
  {tableNode}
1692
1906
  </Table.ScrollContainer>
1907
+ ) : (
1908
+ // The column fold's own measuring box (`useColumnFold`) — wraps every remaining shape,
1909
+ // `maxHeight`-only included: a vertical cap is orthogonal to column width, so a
1910
+ // `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend) still
1911
+ // folds instead of silently regressing to horizontal scroll. A plain block box, so it
1912
+ // constrains nothing on its own; `columnFold.wrapperRef` only reads it.
1913
+ <div ref={columnFold.wrapperRef} className={classes.foldWrapper}>
1914
+ {pageStickyHeader ? (
1915
+ // Pinning takes this wrapper too — a pinned column's offsets are `position: sticky` on
1916
+ // the cells and need no overflow box of their own, so the `overflow-x: auto` Box that
1917
+ // used to sit here was the exact inert-sticky defect this shape exists to avoid.
1918
+ <div
1919
+ ref={containment.wrapperRef}
1920
+ className={classes.containment}
1921
+ data-contained={containment.contained ? 'true' : 'false'}
1922
+ >
1923
+ {tableNode}
1924
+ </div>
1925
+ ) : (
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}>
1929
+ {tableNode}
1930
+ </Table.ScrollContainer>
1931
+ )}
1932
+ </div>
1693
1933
  )}
1694
1934
  {enablePagination && (
1695
1935
  <Group
@@ -1705,6 +1945,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1705
1945
  {totalIsAuthoritative ? ` of ${total}` : ''}
1706
1946
  </Text>
1707
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. */}
1708
1950
  <Select
1709
1951
  size="ctl"
1710
1952
  radius="md"
@@ -1714,13 +1956,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1714
1956
  onChange={(value) => value && table.setPageSize(Number(value))}
1715
1957
  allowDeselect={false}
1716
1958
  />
1717
- <Pagination
1718
- size="sm"
1719
- radius="md"
1720
- total={Math.max(table.getPageCount(), 1)}
1721
- value={paginationState.pageIndex + 1}
1722
- onChange={(page) => table.setPageIndex(page - 1)}
1723
- />
1959
+ <CtlSlot>
1960
+ {/* `Pagination` carries no `ctl`-tier entry in `CTL_THEME` (its own controls resolve
1961
+ the 44px coarse hit floor through `PaginationControl`'s theme-level
1962
+ `data-basalt-hit`, not through the `ctl` size), so `CtlSlot` cannot restore this
1963
+ size on its own — an explicit `size="sm"` is the visual this footer always had. */}
1964
+ <Pagination
1965
+ size="sm"
1966
+ radius="md"
1967
+ total={Math.max(table.getPageCount(), 1)}
1968
+ value={paginationState.pageIndex + 1}
1969
+ onChange={(page) => table.setPageIndex(page - 1)}
1970
+ />
1971
+ </CtlSlot>
1724
1972
  </Group>
1725
1973
  </Group>
1726
1974
  )}