basalt-ui 1.30.2 → 1.31.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 (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/MIGRATING.md +172 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -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 +54 -41
  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 +145 -131
  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 +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +214 -177
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +25 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -51,11 +51,13 @@ import type {
51
51
  ColumnPinningState,
52
52
  FilterFn,
53
53
  PaginationState,
54
+ Row,
54
55
  RowData,
55
56
  RowSelectionState,
56
57
  SortingState,
57
58
  Table as TanstackTable,
58
59
  Updater,
60
+ VisibilityState,
59
61
  } from '@tanstack/react-table'
60
62
  import {
61
63
  flexRender,
@@ -68,13 +70,16 @@ import {
68
70
  import type {
69
71
  CSSProperties,
70
72
  KeyboardEvent as ReactKeyboardEvent,
73
+ MouseEvent as ReactMouseEvent,
71
74
  ReactNode,
72
75
  RefObject,
73
76
  } from 'react'
74
- import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
77
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
75
78
  import { cx } from '../common/props'
76
79
  import type { BasaltProps, SlotStylesProps } from '../common/props'
77
80
  import { BASALT_PREFIX } from '../common/errors'
81
+ import { HIT_COARSE } from '../common/hit-floor'
82
+ import { useMeasuredWidths } from '../common/use-measured-widths'
78
83
  import { assertRequiredProps, useValidateProps } from '../common/validate'
79
84
  import { FilterSet } from '../controls'
80
85
  import { ActionGroup, BarActionSlot } from '../controls/actions'
@@ -121,6 +126,14 @@ declare module '@tanstack/react-table' {
121
126
  * `<td>`'s mono font then overrides what the cell asked for.
122
127
  */
123
128
  numeral?: boolean
129
+ /**
130
+ * Fold priority for the measured column fold (`docs/CONTROLS-SPEC.md` §2): once the
131
+ * table no longer fits its wrapper, the HIGHEST-numbered priority column folds first, moving
132
+ * into a per-row disclosure instead of forcing the table into horizontal scroll. Unset columns
133
+ * fall back to their declared position, so a bare `<BasaltDataTable data columns />` folds its
134
+ * LAST column first — nothing regresses with no `meta.priority` set anywhere. See `foldOrder`.
135
+ */
136
+ priority?: number
124
137
  }
125
138
  }
126
139
 
@@ -315,32 +328,37 @@ const SELECT_COLUMN_ID = '__basalt_select__'
315
328
  * Built per render inside a `useMemo`, not hoisted as a constant: `ColumnDef` is generic in `T`, and
316
329
  * a single shared instance would have to be cast at every call site.
317
330
  *
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.
331
+ * The select cell's `stopPropagation` (see the `<Table.Td>`) is the load-bearing line. The checkbox
332
+ * lives inside the row, and a row carrying `onRowActivate` opens a detail from a click — so without
333
+ * it, ticking the box also navigated away from the table the tick was meant to act on.
321
334
  */
322
335
  function selectionColumn<T>(): ColumnDef<T, unknown> {
323
336
  return {
324
337
  id: SELECT_COLUMN_ID,
325
338
  enableSorting: false,
326
339
  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
- />
340
+ // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
341
+ <label className={classes.selectHit}>
342
+ <Checkbox
343
+ size="xs"
344
+ aria-label="Select all rows"
345
+ checked={table.getIsAllPageRowsSelected()}
346
+ indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
347
+ onChange={table.getToggleAllPageRowsSelectedHandler()}
348
+ />
349
+ </label>
334
350
  ),
335
351
  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
- />
352
+ // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
353
+ <label className={classes.selectHit}>
354
+ <Checkbox
355
+ size="xs"
356
+ aria-label={`Select row ${row.index + 1}`}
357
+ checked={row.getIsSelected()}
358
+ disabled={!row.getCanSelect()}
359
+ onChange={row.getToggleSelectedHandler()}
360
+ />
361
+ </label>
344
362
  ),
345
363
  }
346
364
  }
@@ -884,6 +902,217 @@ function getPinnedCellStyle<T>(
884
902
  }
885
903
  }
886
904
 
905
+ // ── Column fold (`meta.priority`, docs/CONTROLS-SPEC.md §2) ──────────────────
906
+
907
+ /** The column id TanStack will resolve internally for this raw def — explicit `id`, else the
908
+ * accessor key string. Mirrors the resolution `facetColumns` already relies on above. */
909
+ function columnDefId<T>(column: ColumnDef<T, unknown>): string | undefined {
910
+ if (column.id !== undefined) return column.id
911
+ return 'accessorKey' in column && typeof column.accessorKey === 'string'
912
+ ? column.accessorKey
913
+ : undefined
914
+ }
915
+
916
+ /**
917
+ * Fold order for `meta.priority` — the FIRST id in the returned list folds first. Effective
918
+ * priority is `meta.priority ?? declaredIndex`, so with no `meta.priority` set anywhere it is just
919
+ * the column's position: the LAST declared column folds first, the zero-config default a bare
920
+ * `<BasaltDataTable data columns />` needs. An explicit priority folds a HIGHER number before a
921
+ * lower one; a tie folds in reverse declared order. A column with no resolvable id (an
922
+ * `accessorFn` with no explicit `id`) is never a fold candidate — nothing downstream could
923
+ * reliably re-identify it once hidden.
924
+ */
925
+ function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
926
+ return columns
927
+ .map((column, index) => ({
928
+ id: columnDefId(column),
929
+ priority: column.meta?.priority ?? index,
930
+ index,
931
+ }))
932
+ .filter((candidate): candidate is { id: string; priority: number; index: number } =>
933
+ Boolean(candidate.id),
934
+ )
935
+ .toSorted((a, b) => b.priority - a.priority || b.index - a.index)
936
+ .map((candidate) => candidate.id)
937
+ }
938
+
939
+ /** Marks a rendered data column header for `useColumnFold`'s width measurement — never read past
940
+ * that hook, so it carries no styling and needs no CSS-module counterpart. */
941
+ const FOLD_ID_ATTR = 'data-basalt-fold-id'
942
+
943
+ const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
944
+
945
+ /**
946
+ * Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
947
+ * still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
948
+ * or `enableHiding: false` — which occupy room but can never fold) and the disclosure toggle's own
949
+ * width, once anything is folded — fit `room`. A candidate with no measured width yet (never
950
+ * rendered) is skipped rather than folded blind, since folding it would remove the one place its
951
+ * width could ever be read from.
952
+ */
953
+ export function planColumnFold(input: {
954
+ room: number
955
+ toggleWidth: number
956
+ overhead: number
957
+ order: readonly string[]
958
+ widths: ReadonlyMap<string, number>
959
+ }): ReadonlySet<string> {
960
+ const { room, toggleWidth, overhead, order, widths } = input
961
+ const known = order.filter((id) => widths.has(id))
962
+ let unfolded = overhead + known.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
963
+ const folded = new Set<string>()
964
+ for (const id of known) {
965
+ // Never fold the last remaining data column — a table with every column folded has no row
966
+ // identifier left to read at all, which is worse than the horizontal scroll this exists to
967
+ // avoid. With no explicit `meta.priority` this is `order`'s LAST id, which `foldOrder`'s own
968
+ // doc pins as the FIRST declared column (the last-declared one folds first).
969
+ if (folded.size >= known.length - 1) break
970
+ const withToggle = unfolded + (folded.size > 0 ? toggleWidth : 0)
971
+ if (withToggle <= room) break
972
+ folded.add(id)
973
+ unfolded -= widths.get(id) ?? 0
974
+ }
975
+ return folded
976
+ }
977
+
978
+ /**
979
+ * The general-case measured fold. Reacting to `useMeasuredContainment`'s boolean fit directly
980
+ * (fold one, remeasure, maybe unfold) would oscillate, because folding changes the very box being
981
+ * measured. Instead this reads each candidate `<th data-basalt-fold-id>`'s width once, while it is
982
+ * visible, caches it, and decides analytically (`planColumnFold`). `useMeasuredWidths` owns the
983
+ * cache and the wrapper-vs-table observer that `controls/actions.tsx`'s fold shares.
984
+ */
985
+ function useColumnFold(input: {
986
+ active: boolean
987
+ order: readonly string[]
988
+ /**
989
+ * Columns excluded from fold candidacy altogether (pinned, or `enableHiding: false`) — they are
990
+ * never in `order`, but they still occupy room, and their measured widths (carried in the same
991
+ * `[data-basalt-fold-id]` sweep as every eligible column's) become fixed overhead against the
992
+ * fold baseline. See `planColumnFold`'s `overhead` param.
993
+ */
994
+ fixedIds: readonly string[]
995
+ }): {
996
+ wrapperRef: RefObject<HTMLDivElement | null>
997
+ folded: ReadonlySet<string>
998
+ } {
999
+ const { active, order, fixedIds } = input
1000
+ const wrapperRef = useRef<HTMLDivElement>(null)
1001
+ const [folded, setFolded] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
1002
+ const orderRef = useRef(order)
1003
+ orderRef.current = order
1004
+ const fixedIdsRef = useRef(fixedIds)
1005
+ fixedIdsRef.current = fixedIds
1006
+ useLayoutEffect(() => {
1007
+ if (!active || order.length === 0) {
1008
+ setFolded((current) => (current.size === 0 ? current : EMPTY_FOLD_SET))
1009
+ }
1010
+ }, [active, order.length])
1011
+
1012
+ useMeasuredWidths({
1013
+ resolveRoot: () => wrapperRef.current,
1014
+ signature: `${order.join('|')}::${fixedIds.join('|')}`,
1015
+ enabled: active && order.length > 0,
1016
+ // The table too, not only the wrapper: a re-measured column set moves its min-content width
1017
+ // while the wrapper's own box never changes.
1018
+ extra: (root) => [root.querySelector('table')],
1019
+ onMeasure: (root, boxes) => {
1020
+ for (const cell of root.querySelectorAll<HTMLElement>(`[${FOLD_ID_ATTR}]`)) {
1021
+ const id = cell.getAttribute(FOLD_ID_ATTR)
1022
+ if (id !== null && cell.offsetWidth > 0) {
1023
+ boxes.current.set(id, { width: cell.offsetWidth, height: cell.offsetHeight })
1024
+ }
1025
+ }
1026
+ const room = root.clientWidth
1027
+ // A zero reading is an un-laid-out ancestor, not a table with no room — the same guard
1028
+ // `useMeasuredContainment` above runs against the aside animating in from 0.
1029
+ if (room === 0) return
1030
+ const widths = new Map([...boxes.current].map(([id, box]) => [id, box.width]))
1031
+ const overhead = fixedIdsRef.current.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
1032
+ const next = planColumnFold({
1033
+ room,
1034
+ // The disclosure toggle is a fixed icon-button cell matched to the coarse hit floor, so it
1035
+ // reads as one more `--vx-hit` control rather than a narrower afterthought.
1036
+ toggleWidth: HIT_COARSE,
1037
+ overhead,
1038
+ order: orderRef.current,
1039
+ widths,
1040
+ })
1041
+ setFolded((current) =>
1042
+ current.size === next.size && [...current].every((id) => next.has(id)) ? current : next,
1043
+ )
1044
+ },
1045
+ })
1046
+
1047
+ return { wrapperRef, folded: active ? folded : EMPTY_FOLD_SET }
1048
+ }
1049
+
1050
+ /** The label half of a folded column's disclosure row — its own header when it is a plain string
1051
+ * (the common case), else the column id, since a function/`ReactNode` header has no header
1052
+ * CONTEXT to render with here (this is a body row, not the `<thead>`). */
1053
+ function columnLabel<T>(column: Column<T, unknown>): string {
1054
+ const header = column.columnDef.header
1055
+ return typeof header === 'string' ? header : column.id
1056
+ }
1057
+
1058
+ /** One row's folded columns as label/value pairs, directly under it. `row.getAllCells()` returns
1059
+ * every leaf cell regardless of visibility (unlike `getVisibleCells()`), so a folded column's own
1060
+ * `cell` renderer still draws here exactly as it would in the `<td>` it no longer occupies. */
1061
+ function RowDisclosure<T>({
1062
+ row,
1063
+ folded,
1064
+ }: {
1065
+ row: Row<T>
1066
+ folded: ReadonlySet<string>
1067
+ }): ReactNode {
1068
+ const cells = row.getAllCells().filter((cell) => folded.has(cell.column.id))
1069
+ return (
1070
+ <div className={classes.foldDisclosure}>
1071
+ {cells.map((cell) => (
1072
+ <div key={cell.id} className={classes.foldDisclosureRow}>
1073
+ <Text className={classes.foldDisclosureLabel}>{columnLabel(cell.column)}</Text>
1074
+ <div className={classes.foldDisclosureValue}>
1075
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1076
+ </div>
1077
+ </div>
1078
+ ))}
1079
+ </div>
1080
+ )
1081
+ }
1082
+
1083
+ /** The per-row expand/collapse control the fold-disclosure column renders — a text glyph, since
1084
+ * basalt ships no icon set (`docs/CONTROLS-SPEC.md` §2, mirrors `SortIndicator` above). */
1085
+ function FoldToggle({
1086
+ expanded,
1087
+ controlsId,
1088
+ onToggle,
1089
+ }: {
1090
+ expanded: boolean
1091
+ controlsId: string
1092
+ onToggle: () => void
1093
+ }): ReactNode {
1094
+ return (
1095
+ <button
1096
+ type="button"
1097
+ className={classes.foldToggle}
1098
+ data-basalt-hit
1099
+ aria-expanded={expanded}
1100
+ aria-controls={controlsId}
1101
+ aria-label={expanded ? 'Hide row details' : 'Show row details'}
1102
+ // A row carrying `onRowActivate` reads a bubbled click as "activate the row" — the toggle
1103
+ // would open/close its disclosure AND fire the row's own action underneath it.
1104
+ onClick={(event) => {
1105
+ event.stopPropagation()
1106
+ onToggle()
1107
+ }}
1108
+ >
1109
+ <Box component="span" aria-hidden>
1110
+ {expanded ? '▾' : '▸'}
1111
+ </Box>
1112
+ </button>
1113
+ )
1114
+ }
1115
+
887
1116
  // ── The manual-pagination contract ────────────────────────────────────────────
888
1117
 
889
1118
  /** One client-side control that `manualPagination` turns into a claim the table cannot support. */
@@ -973,38 +1202,20 @@ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): vo
973
1202
  * statically: `stickyHeader` with neither `maxHeight` nor `minWidth`.
974
1203
  *
975
1204
  * 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.
983
- *
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.
991
- *
992
- * Three properties that are easy to lose:
1205
+ * sizes to its own min-content and would drag the page sideways. This shape cannot take that
1206
+ * container unconditionally: an `overflow-x: auto` box also computes `overflow-y`, making it the
1207
+ * sticky header's own scrollport with no scroll range, so the `<thead>` scrolls clean away.
993
1208
  *
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.
1209
+ * So the wrapper's overflow is MEASURED. While the table FITS, the wrapper is bare and the sticky
1210
+ * header sticks; once it is WIDER, the wrapper flips to `overflow-x: auto` and the header is inert.
1211
+ * The trade is deliberate: a header that stops sticking is recoverable, a page that scrolls
1212
+ * sideways is not.
1004
1213
  *
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.
1214
+ * Three invariants: the observer stays alive in BOTH states (no one-way latch, so the wrapper
1215
+ * reverts when the space returns); equality counts as FITS (a `width: 100%` table reads
1216
+ * `offsetWidth === clientWidth`, which stops oscillation); and a zero `clientWidth` is `unknown`,
1217
+ * not overflow (the aside animates its width in from 0). `useLayoutEffect` runs before paint, so an
1218
+ * already-measurable table resolves without a flash; the default is bare for SSR and first paint.
1008
1219
  */
1009
1220
  function useMeasuredContainment(active: boolean): {
1010
1221
  wrapperRef: RefObject<HTMLDivElement | null>
@@ -1013,50 +1224,26 @@ function useMeasuredContainment(active: boolean): {
1013
1224
  const wrapperRef = useRef<HTMLDivElement>(null)
1014
1225
  const [contained, setContained] = useState(false)
1015
1226
 
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')
1227
+ // The same measured-fold core `useColumnFold` uses, so this shape no longer mounts a second
1228
+ // bespoke `ResizeObserver` on the wrapper + table.
1229
+ useMeasuredWidths({
1230
+ resolveRoot: () => wrapperRef.current,
1231
+ signature: String(active),
1232
+ enabled: active,
1047
1233
  // The TABLE too, not only the wrapper: a column set that re-measures (a facet filter changing
1048
1234
  // the widest cell, a font settling) moves the table's min-content width while the wrapper's
1049
1235
  // 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])
1236
+ extra: (root) => [root.querySelector('table')],
1237
+ onMeasure: (root) => {
1238
+ const table = root.querySelector('table')
1239
+ // Three answers, not two — see the docblock. A zero reading is an un-laid-out ancestor
1240
+ // (the aside animating in from 0), not overflow, so it moves neither the state nor the
1241
+ // observer; equality counts as FITS and stops the fit/contained oscillation.
1242
+ if (table === null || root.clientWidth === 0 || table.offsetWidth === 0) return
1243
+ const next = table.offsetWidth > root.clientWidth
1244
+ setContained((current) => (current === next ? current : next))
1245
+ },
1246
+ })
1060
1247
 
1061
1248
  return { wrapperRef, contained: active && contained }
1062
1249
  }
@@ -1268,7 +1455,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1268
1455
  if (!facets || facets.length === 0) return columns
1269
1456
  const facetById = new Map(facets.map((facet) => [facet.columnId, facet]))
1270
1457
  return columns.map((column) => {
1271
- const id = column.id ?? (column as { accessorKey?: string }).accessorKey
1458
+ const id = columnDefId(column)
1272
1459
  const facet = id === undefined ? undefined : facetById.get(id)
1273
1460
  if (!facet) return column
1274
1461
  const filterFn: FilterFn<T> = facet.multiple
@@ -1318,6 +1505,97 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1318
1505
  const filteringEnabled = !breaches.includes('filtering')
1319
1506
  const totalIsAuthoritative = !breaches.includes('total')
1320
1507
 
1508
+ // A capped body or a horizontal floor both need the same native scroll node — computed here,
1509
+ // ahead of the table, because `pageStickyHeader` (further down) needs it: `maxHeight` alone still
1510
+ // wants its own `ScrollArea` scrollport (a sticky `<thead>` resolves against it, not the measured
1511
+ // containment div, which offers no vertical scroll box at all), so EITHER prop rules out the
1512
+ // measured-containment shape.
1513
+ const scrolls = maxHeight !== undefined || minWidth !== undefined
1514
+
1515
+ // Fold eligibility is narrower than `scrolls`: only `minWidth` is a genuinely DECLARED horizontal
1516
+ // floor — the caller is stating "this table is at least this wide," which folding columns out of
1517
+ // would contradict. `maxHeight` alone is a purely VERTICAL cap and orthogonal to column width, so
1518
+ // a `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend for a
1519
+ // capped, sticky-header body) still folds instead of silently regressing to horizontal scroll.
1520
+ const foldEligible = minWidth === undefined
1521
+
1522
+ // The fold order is computed off the caller's OWN `columns` — never `tableColumns` — so the
1523
+ // selection checkbox (prepended below) is never a fold candidate; see `foldOrder`'s docblock for
1524
+ // the declared-order default and the explicit-priority rule. `enableHiding: false` and a PINNED
1525
+ // column are excluded outright, not merely de-prioritized — both are the caller stating this
1526
+ // column must stay visible, the same declaration a fold candidate has no way to override.
1527
+ const { columnFoldOrder, columnFoldFixedIds } = useMemo(() => {
1528
+ const pinnedIds = new Set([...(columnPinning.left ?? []), ...(columnPinning.right ?? [])])
1529
+ const excluded = new Set(
1530
+ columns.flatMap((column) => {
1531
+ const id = columnDefId(column)
1532
+ if (id === undefined) return []
1533
+ return column.enableHiding === false || pinnedIds.has(id) ? [id] : []
1534
+ }),
1535
+ )
1536
+ return {
1537
+ columnFoldOrder: foldOrder(columns).filter((id) => !excluded.has(id)),
1538
+ columnFoldFixedIds: [...excluded],
1539
+ }
1540
+ }, [columns, columnPinning])
1541
+ const columnFold = useColumnFold({
1542
+ active: foldEligible,
1543
+ order: columnFoldOrder,
1544
+ fixedIds: columnFoldFixedIds,
1545
+ })
1546
+ const columnVisibility: VisibilityState = useMemo(
1547
+ () => Object.fromEntries([...columnFold.folded].map((id) => [id, false])),
1548
+ [columnFold.folded],
1549
+ )
1550
+ 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])
1598
+
1321
1599
  const table = useReactTable<T>({
1322
1600
  data,
1323
1601
  columns: tableColumns,
@@ -1326,6 +1604,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1326
1604
  globalFilter,
1327
1605
  columnFilters,
1328
1606
  columnPinning,
1607
+ columnVisibility,
1329
1608
  ...(enablePagination && { pagination }),
1330
1609
  ...(enableRowSelection && { rowSelection: rowSelectionState }),
1331
1610
  },
@@ -1371,10 +1650,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1371
1650
  const showSkeleton = branch === undefined ? isLoading : branch === 'pending'
1372
1651
  const queryError = branch === 'error' && query !== undefined ? query : undefined
1373
1652
 
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)
1653
+ // Every colSpan and every skeleton row counts the RENDERED columns: one more than `columns` for
1654
+ // the prepended checkbox column, one more again for the fold-disclosure toggle once anything has
1655
+ // folded — a stale count left the empty state and the error row short of the header.
1656
+ const columnCount =
1657
+ columns.length + (enableRowSelection ? 1 : 0) + (hasFolded ? 1 : 0) - columnFold.folded.size
1378
1658
 
1379
1659
  // `getSelectedRowModel()` is TanStack's own memo, so its `rows` array keeps its identity until
1380
1660
  // the selection or the row model actually moves — which is what makes both memos below hold.
@@ -1400,8 +1680,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1400
1680
 
1401
1681
  // A capped body or a horizontal floor both need the same native scroll node. Everything else —
1402
1682
  // 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
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.)
1405
1685
  // `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
1406
1686
  // scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
1407
1687
  // always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
@@ -1440,6 +1720,9 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1440
1720
  <Table.Thead>
1441
1721
  {headerGroups.map((headerGroup) => (
1442
1722
  <Table.Tr key={headerGroup.id}>
1723
+ {/* The disclosure toggle column has no header of its own — nothing is "sorted" or
1724
+ "selected all" across it, only revealed per row. */}
1725
+ {hasFolded && <Table.Th className={classes.foldToggleCell} aria-hidden />}
1443
1726
  {headerGroup.headers.map((header) => {
1444
1727
  const canSort = sortingEnabled && header.column.getCanSort()
1445
1728
  const sorted = header.column.getIsSorted()
@@ -1448,9 +1731,14 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1448
1731
  ? getPinnedCellStyle(table, header.column)
1449
1732
  : undefined
1450
1733
  const align = resolveAlign(header.column)
1734
+ const isSelectColumn = header.column.id === SELECT_COLUMN_ID
1451
1735
  return (
1452
1736
  <Table.Th
1453
1737
  key={header.id}
1738
+ {...(isSelectColumn && { className: classes.selectCell })}
1739
+ // The measured fold's own read (`useColumnFold`) — never present on the
1740
+ // selection column, which is never a fold candidate.
1741
+ {...(!isSelectColumn && { [FOLD_ID_ATTR]: header.column.id })}
1454
1742
  onClick={canSort ? toggleSorting : undefined}
1455
1743
  onKeyDown={
1456
1744
  canSort
@@ -1532,51 +1820,77 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1532
1820
  ...row.getRightVisibleCells(),
1533
1821
  ]
1534
1822
  : row.getVisibleCells()
1823
+ const rowExpanded = hasFolded && expandedFoldRows.has(row.id)
1535
1824
  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())}
1825
+ <Fragment key={row.id}>
1826
+ <Table.Tr
1827
+ {...(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
+ })}
1846
+ >
1847
+ {hasFolded && (
1848
+ <Table.Td className={classes.foldToggleCell}>
1849
+ <FoldToggle
1850
+ expanded={rowExpanded}
1851
+ controlsId={`${row.id}-fold`}
1852
+ onToggle={() => toggleFoldRow(row.id)}
1853
+ />
1576
1854
  </Table.Td>
1577
- )
1578
- })}
1579
- </Table.Tr>
1855
+ )}
1856
+ {cells.map((cell) => {
1857
+ const pinnedStyle = enablePinning
1858
+ ? getPinnedCellStyle(table, cell.column)
1859
+ : undefined
1860
+ const align = resolveAlign(cell.column)
1861
+ return (
1862
+ <Table.Td
1863
+ key={cell.id}
1864
+ {...(cell.column.id === SELECT_COLUMN_ID && {
1865
+ className: classes.selectCell,
1866
+ // The label's ::after fills the cell, so a tap on it bubbles to the row —
1867
+ // stop it only where a tick is possible; a disabled row's tap is a row tap.
1868
+ ...(cell.row.getCanSelect() && {
1869
+ onClick: (event: ReactMouseEvent) => event.stopPropagation(),
1870
+ }),
1871
+ })}
1872
+ style={{
1873
+ ...(typeof cell.getValue() === 'number' &&
1874
+ cell.column.columnDef.meta?.numeral !== false
1875
+ ? NUMERIC_CELL_STYLE
1876
+ : undefined),
1877
+ ...(align !== undefined && { textAlign: align }),
1878
+ ...pinnedStyle,
1879
+ }}
1880
+ >
1881
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1882
+ </Table.Td>
1883
+ )
1884
+ })}
1885
+ </Table.Tr>
1886
+ {rowExpanded && (
1887
+ <Table.Tr id={`${row.id}-fold`} className={classes.foldDisclosureRowTr}>
1888
+ <Table.Td colSpan={columnCount} className={classes.foldDisclosureCell}>
1889
+ <RowDisclosure row={row} folded={columnFold.folded} />
1890
+ </Table.Td>
1891
+ </Table.Tr>
1892
+ )}
1893
+ </Fragment>
1580
1894
  )
1581
1895
  })
1582
1896
  )}
@@ -1671,25 +1985,44 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1671
1985
  {bulkBarActions !== undefined && (
1672
1986
  <BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
1673
1987
  )}
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
- >
1988
+ {!foldEligible ? (
1989
+ // An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
1990
+ // never engages here (see `foldEligible`, above), so this shape keeps its exact
1991
+ // pre-existing DOM: no extra measuring wrapper for a table nothing folds.
1992
+ <Table.ScrollContainer type="native" minWidth={minWidth ?? 0} maxHeight={maxHeight}>
1691
1993
  {tableNode}
1692
1994
  </Table.ScrollContainer>
1995
+ ) : (
1996
+ // The column fold's own measuring box (`useColumnFold`) — wraps every remaining shape,
1997
+ // `maxHeight`-only included: a vertical cap is orthogonal to column width, so a
1998
+ // `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend) still
1999
+ // folds instead of silently regressing to horizontal scroll. A plain block box, so it
2000
+ // constrains nothing on its own; `columnFold.wrapperRef` only reads it.
2001
+ <div ref={columnFold.wrapperRef} className={classes.foldWrapper}>
2002
+ {pageStickyHeader ? (
2003
+ // Pinning takes this wrapper too — a pinned column's offsets are `position: sticky` on
2004
+ // the cells and need no overflow box of their own, so the `overflow-x: auto` Box that
2005
+ // used to sit here was the exact inert-sticky defect this shape exists to avoid.
2006
+ <div
2007
+ ref={containment.wrapperRef}
2008
+ className={classes.containment}
2009
+ data-contained={containment.contained ? 'true' : 'false'}
2010
+ >
2011
+ {tableNode}
2012
+ </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
+ ) : (
2021
+ <Table.ScrollContainer type="native" minWidth={0}>
2022
+ {tableNode}
2023
+ </Table.ScrollContainer>
2024
+ )}
2025
+ </div>
1693
2026
  )}
1694
2027
  {enablePagination && (
1695
2028
  <Group
@@ -1714,13 +2047,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
1714
2047
  onChange={(value) => value && table.setPageSize(Number(value))}
1715
2048
  allowDeselect={false}
1716
2049
  />
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
- />
2050
+ <CtlSlot>
2051
+ {/* `Pagination` carries no `ctl`-tier entry in `CTL_THEME` (its own controls resolve
2052
+ the 44px coarse hit floor through `PaginationControl`'s theme-level
2053
+ `data-basalt-hit`, not through the `ctl` size), so `CtlSlot` cannot restore this
2054
+ size on its own — an explicit `size="sm"` is the visual this footer always had. */}
2055
+ <Pagination
2056
+ size="sm"
2057
+ radius="md"
2058
+ total={Math.max(table.getPageCount(), 1)}
2059
+ value={paginationState.pageIndex + 1}
2060
+ onChange={(page) => table.setPageIndex(page - 1)}
2061
+ />
2062
+ </CtlSlot>
1724
2063
  </Group>
1725
2064
  </Group>
1726
2065
  )}