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
@@ -12,15 +12,43 @@
12
12
  * The header renders only when at least one of title/info/value/actions/icon/count is set — ending
13
13
  * the `''`-as-hidden-header sentinel a consumer used to reach for otherwise.
14
14
  */
15
+ import { useMemo, useReducer, useState } from 'react'
15
16
  import type { CSSProperties, ReactNode } from 'react'
16
17
  import { cx } from '../../common/props'
17
18
  import type { BasaltProps, SlotStylesProps } from '../../common/props'
18
19
  import { VX } from '../../tokens'
19
20
  import { WidgetHeader } from '../../dashboard/widget-header'
20
21
  import type { DeltaPolarity } from '../../dashboard/delta-badge'
22
+ import { useChartSize } from '../hooks/useChartSize'
23
+ import { useHysteresis } from '../hooks/use-hysteresis'
24
+ import { ChartCardContext, legendSlotReducer } from './chart-card-context'
21
25
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
22
26
  import type { ChartState } from './ChartPending'
23
27
 
28
+ /**
29
+ * Below this rendered height the header folds its subtitle regardless of width (wave 8's
30
+ * header-economy law, `docs/CHARTS-SPEC.md` §5) — a short-but-wide card (a KPI-style
31
+ * chart card) still needs the vertical space back. A `@container basalt-card` SIZE query would be
32
+ * the natural expression, but `container-type: size` on a box with no explicit height (the common,
33
+ * non-grid-stretched case) collapses it to 0 — CSS containment requires a definite size in the
34
+ * contained axis, and a ChartCard's height is ordinarily derived from its content (VERIFIED: making
35
+ * the root a size container drops `card-chrome.layout.test.ts`'s unstretched short-card fixture to
36
+ * a 0px box). `useChartSize` (the same measuring primitive `ChartFrame` uses) reads the rendered
37
+ * height instead, and `data-basalt-card-short` is the CSS hook `widget-header.module.css` reacts to
38
+ * with the same subtitle-fold declarations as the width query.
39
+ */
40
+ const CARD_SHORT_HEIGHT = 280
41
+ /**
42
+ * Hysteresis band on the flag above (`docs/waves/PLAN.md` wave 3, P0-1). `short` folds the header,
43
+ * which changes the card's OWN measured height by the fold delta — a card whose unfolded height
44
+ * lands in `[280, 280 + Δ)` is bistable, and the async RO → rAF → debounce → setState → commit path
45
+ * can sit one reading behind, which turned the bistability into a perpetual flip-flop on the
46
+ * "Sales by channel" dashboard card (27-52 times per 2s, every viewport). Entering short still
47
+ * happens below `CARD_SHORT_HEIGHT`; LEAVING it needs `CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK`, sized
48
+ * to clear the largest fold delta (subtitle line + inset + value row) with headroom.
49
+ */
50
+ const SHORT_EXIT_SLACK = 24
51
+
24
52
  // Surfaces resolve per theme via CSS vars, so the styles are static (no useMemo/isDark).
25
53
  // Depth = `shadow-card` (a whisper shadow + a 1px ring baked into the same value), never a
26
54
  // `border` property (docs/DESIGN-SPEC.md §5) — so a ChartCard reads IDENTICALLY to a Mantine
@@ -31,7 +59,16 @@ import type { ChartState } from './ChartPending'
31
59
  // `WidgetHeader`'s info bubble (`widget-header.module.css`'s `.infoBubble`, which is where the
32
60
  // tooltip this file used to own now lives) opens downward past the header's own box, and must be
33
61
  // able to overhang the card edge without being invisibly cut off.
62
+ //
63
+ // The root is a flex column and a `basalt-card` inline-size container (the card's own width drives
64
+ // its header chrome, `docs/CHARTS-SPEC.md` §8): the body takes the row's spare height when a grid stretches
65
+ // the card.
34
66
  const cardStyle: CSSProperties = {
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ minHeight: 0,
70
+ containerType: 'inline-size',
71
+ containerName: 'basalt-card',
35
72
  borderRadius: VX.radiusCard,
36
73
  boxShadow: VX.shadowCard,
37
74
  backgroundColor: VX.surface.panel,
@@ -45,6 +82,11 @@ const bodyClipStyle: CSSProperties = {
45
82
  borderBottomLeftRadius: VX.radiusCard,
46
83
  borderBottomRightRadius: VX.radiusCard,
47
84
  overflow: 'hidden',
85
+ flex: '1 1 auto',
86
+ minHeight: 0,
87
+ // A column, so a `ChartFrame` inside can grow into the body a stretched grid row hands the card.
88
+ display: 'flex',
89
+ flexDirection: 'column',
48
90
  padding: '2px var(--vx-space-card-inset-x, 0.8125rem) var(--vx-space-card-inset-y, 0.6875rem)',
49
91
  }
50
92
  // Card inset = the `cardInsetY`/`cardInsetX` role tokens (vertical / horizontal), the SAME two
@@ -60,6 +102,13 @@ const bodyClipStyle: CSSProperties = {
60
102
  const headerWrapStyle: CSSProperties = {
61
103
  padding: 'var(--vx-space-card-inset-y, 0.6875rem) var(--vx-space-card-inset-x, 0.8125rem) 4px',
62
104
  }
105
+ // A short card (wave 8) trims the header's own top inset — the subtitle line and the wide-tier
106
+ // value/delta row are both already gone by the time `short` is true, so the full card inset reads
107
+ // as dead air rather than rhythm. Bottom stays 4px (unchanged) so the header-to-body seam matches
108
+ // every other ChartCard.
109
+ const headerWrapShortStyle: CSSProperties = {
110
+ padding: '4px var(--vx-space-card-inset-x, 0.8125rem) 4px',
111
+ }
63
112
 
64
113
  /**
65
114
  * The three boxes a `ChartCard` paints, and therefore the slot set a consumer may class
@@ -143,6 +192,8 @@ export function ChartCard({
143
192
  style,
144
193
  children,
145
194
  }: ChartCardProps) {
195
+ const [legendSlot, setLegendSlot] = useState<HTMLDivElement | null>(null)
196
+ const { ref: sizeRef, height: measuredHeight } = useChartSize()
146
197
  const resolvedState = resolveChartState({ ...(state !== undefined && { state }) })
147
198
  const hasHeader =
148
199
  title !== undefined ||
@@ -151,59 +202,79 @@ export function ChartCard({
151
202
  actions !== undefined ||
152
203
  icon !== undefined ||
153
204
  count !== undefined
205
+ // Hysteresis (P0-1, `SHORT_EXIT_SLACK`'s own doc): which threshold applies depends on the
206
+ // PREVIOUS committed flag, so a card whose unfolded height sits in the bistable band settles
207
+ // instead of flip-flopping forever.
208
+ const short = useHysteresis(
209
+ (wasShort) =>
210
+ measuredHeight > 0 &&
211
+ measuredHeight < (wasShort ? CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK : CARD_SHORT_HEIGHT),
212
+ false,
213
+ )
214
+
215
+ const [legendClaimants, dispatchLegendSlot] = useReducer(legendSlotReducer, [])
216
+ const context = useMemo(
217
+ () => ({ legendSlot, inCard: true, short, legendClaimants, dispatchLegendSlot }),
218
+ [legendSlot, short, legendClaimants],
219
+ )
154
220
 
155
221
  return (
156
- <div
157
- className={cx(classNames?.root, className)}
158
- style={{ ...cardStyle, ...style }}
159
- {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
160
- >
161
- {hasHeader && (
162
- <div
163
- {...(classNames?.header !== undefined && { className: classNames.header })}
164
- style={headerWrapStyle}
165
- >
166
- <WidgetHeader
167
- tier="widget"
168
- title={title ?? ''}
169
- {...(icon !== undefined && { icon })}
170
- {...(subtitle !== undefined && { subtitle })}
171
- {...(info !== undefined && { info })}
172
- {...(value !== undefined && { value })}
173
- {...(delta !== undefined && { delta })}
174
- {...(deltaPeriod !== undefined && { deltaPeriod })}
175
- {...(deltaPolarity !== undefined && { deltaPolarity })}
176
- {...(count !== undefined && { count })}
177
- {...(actions !== undefined && {
178
- actions: <span data-basalt-tier="widget">{actions}</span>,
179
- })}
180
- />
181
- </div>
182
- )}
222
+ <ChartCardContext.Provider value={context}>
183
223
  <div
184
- {...(classNames?.body !== undefined && { className: classNames.body })}
185
- style={bodyClipStyle}
224
+ ref={sizeRef}
225
+ className={cx(classNames?.root, className)}
226
+ style={{ ...cardStyle, ...style }}
227
+ {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
228
+ {...(short && { 'data-basalt-card-short': '' })}
186
229
  >
187
- {resolvedState === 'pending' ? (
188
- <ChartPending width="100%" height={placeholderHeight} />
189
- ) : resolvedState === 'error' ? (
190
- <ChartError
191
- width="100%"
192
- height={placeholderHeight}
193
- error={state?.error}
194
- action={stateAction}
195
- />
196
- ) : resolvedState === 'empty' ? (
197
- <ChartEmpty
198
- width="100%"
199
- height={placeholderHeight}
200
- {...(typeof state?.empty === 'string' && { label: state.empty })}
201
- action={stateAction}
202
- />
203
- ) : (
204
- children
230
+ {hasHeader && (
231
+ <div
232
+ {...(classNames?.header !== undefined && { className: classNames.header })}
233
+ style={short ? headerWrapShortStyle : headerWrapStyle}
234
+ >
235
+ <WidgetHeader
236
+ tier="widget"
237
+ title={title ?? ''}
238
+ {...(icon !== undefined && { icon })}
239
+ {...(subtitle !== undefined && { subtitle })}
240
+ {...(info !== undefined && { info })}
241
+ {...(value !== undefined && { value })}
242
+ {...(delta !== undefined && { delta })}
243
+ {...(deltaPeriod !== undefined && { deltaPeriod })}
244
+ {...(deltaPolarity !== undefined && { deltaPolarity })}
245
+ {...(count !== undefined && { count })}
246
+ {...(actions !== undefined && {
247
+ actions: <span data-basalt-tier="widget">{actions}</span>,
248
+ })}
249
+ />
250
+ <div ref={setLegendSlot} data-basalt-legend-slot="" />
251
+ </div>
205
252
  )}
253
+ <div
254
+ {...(classNames?.body !== undefined && { className: classNames.body })}
255
+ style={bodyClipStyle}
256
+ >
257
+ {resolvedState === 'pending' ? (
258
+ <ChartPending width="100%" height={placeholderHeight} />
259
+ ) : resolvedState === 'error' ? (
260
+ <ChartError
261
+ width="100%"
262
+ height={placeholderHeight}
263
+ error={state?.error}
264
+ action={stateAction}
265
+ />
266
+ ) : resolvedState === 'empty' ? (
267
+ <ChartEmpty
268
+ width="100%"
269
+ height={placeholderHeight}
270
+ {...(typeof state?.empty === 'string' && { label: state.empty })}
271
+ action={stateAction}
272
+ />
273
+ ) : (
274
+ children
275
+ )}
276
+ </div>
206
277
  </div>
207
- </div>
278
+ </ChartCardContext.Provider>
208
279
  )
209
280
  }
@@ -1,35 +1,33 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
- import { useCallback, useState } from 'react'
2
+ import { useCallback, useContext, useId, useLayoutEffect, useMemo, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
3
4
  import type { BasaltProps } from '../../common/props'
4
- import { ignoredProp } from '../../common/errors'
5
- import { useValidateProps } from '../../common/validate'
5
+ import {
6
+ deprecatedProp,
7
+ ignoredProp,
8
+ legendSlotContention,
9
+ plotBelowFloor,
10
+ } from '../../common/errors'
11
+ import { reportOnce, useValidateProps } from '../../common/validate'
12
+ import { SizeClassHintContext, useSizeClass } from '../../shell/use-size-class'
6
13
  import { VX } from '../../tokens'
7
14
  import { useChartSize } from '../hooks/useChartSize'
8
15
  import { deriveLegend } from '../series'
9
16
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
10
- import {
11
- resolveChartTier,
12
- resolveFrameHeight,
13
- resolveLegendRollup,
14
- resolvePlotRect,
15
- } from './chart-frame-layout'
17
+ import { ChartCardContext } from './chart-card-context'
18
+ import { resolveChartLayout, resolveFrameClass } from './chart-layout'
19
+ import { resolveFrameHeight, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
16
20
  import type { ResponsiveChartHeight } from './chart-frame-layout'
17
- import { ChartTierProvider } from './chart-tier'
21
+ import { ChartTierProvider, useCoarsePointer, useViewportHeight } from './chart-tier'
18
22
  import { ChartLegend } from './ChartLegend'
19
23
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
20
24
  import type { ChartState } from './ChartPending'
21
25
 
22
26
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
23
27
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
24
- export {
25
- legendEntryCap,
26
- resolveLegendRollup,
27
- resolvePlotRect,
28
- resolveChartTier,
29
- } from './chart-frame-layout'
28
+ export { legendEntryCap, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
30
29
  export type { ResponsiveChartHeight } from './chart-frame-layout'
31
30
 
32
- const DEFAULT_HEIGHT = 240
33
31
  const DEFAULT_MIN_WIDTH = 200
34
32
 
35
33
  /** Legend configuration for {@link ChartFrame}. Omit entirely (or pass `{}`) for the default
@@ -38,22 +36,12 @@ export type ChartFrameLegend = {
38
36
  /** Default 'bottom'. */
39
37
  placement?: LegendPlacement
40
38
  /**
41
- * Rollup cap at high cardinality: the legend renders this many ENTRIES and folds the remainder
42
- * into a `+N more` disclosure chip.
39
+ * @deprecated Removed in 1.32.0 — the legend now fits by measured width every time and folds
40
+ * overflow into an `All N` disclosure (`docs/CHARTS-SPEC.md` §8); drop the prop.
43
41
  *
44
- * **It counts entries, not rows** — the name is inherited and now load-bearing across
45
- * `ChartTierMetrics.legendMaxRows`, so it is not renamed in a patch. `maxRows: 3` on a 7-entry
46
- * legend renders three entries, which is ONE row at any width they fit on. `ChartLegend` slices
47
- * (`entries.slice(0, cap)`); nothing here measures rows.
48
- *
49
- * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
50
- * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
51
- * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
52
- * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
53
- * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
54
- * number a caller stated, so under `fill` the PLOT yields the height instead
55
- * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
56
- * gets what is left — visible, and yours.
42
+ * Until then an explicit value still WINS OUTRIGHT over the measured fit, and it counts ENTRIES,
43
+ * not rows: `maxRows: 3` on a 7-entry legend renders three entries. Under `fill` the PLOT yields
44
+ * the height ({@link resolvePlotRect}'s `legendWins`).
57
45
  */
58
46
  maxRows?: number
59
47
  /** Visually separate role: series | overlay | reference. */
@@ -68,20 +56,32 @@ export type ChartFrameLegend = {
68
56
  * plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
69
57
  */
70
58
  toggle?: boolean
59
+ /**
60
+ * Internal escape hatch for a kind that never wants its legend portalled into a `ChartCard`
61
+ * header, whatever the placement — `Donut` sets this (`docs/waves/PLAN.md` wave 3, P1-4): its
62
+ * side/bottom legend is the ring's only key, so it must stay under or beside the ring rather than
63
+ * follow every other frame's default of claiming an available header slot. `false` keeps the
64
+ * legend in its own band regardless of `placement`; omitted (default) behaves as today. Not
65
+ * something a consumer composing a kind's own `legend` prop can reach — kinds compose `ChartFrame`
66
+ * directly to set it.
67
+ */
68
+ headerSlot?: boolean
71
69
  }
72
70
 
73
71
  export type ChartFrameProps = BasaltProps & {
74
72
  /** Series identity — drives the derived legend. Pass the SAME array the kind draws + tooltips from. */
75
73
  series: readonly SeriesStyle[]
76
74
  /**
77
- * Height in pixels, or one per size step. Used when neither `aspectRatio` nor `fill` is set.
78
- * Default 240. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
75
+ * Height in pixels, or one per container step. Used when neither `aspectRatio` nor `fill` is set.
76
+ * Default: derived from the measured width and container class (`resolveChartLayout`). Inside a
77
+ * `ChartCard` the resulting height is a MINIMUM — the frame grows into a taller card body. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
79
78
  * this is never read, which now warns once in dev (`common/errors.ts`' `ignoredProp`).
80
79
  *
81
- * `{ base: 180, md: 260 }` is the declarative escape from a page of fixed-height charts: the
82
- * steps are compared against the frame's own MEASURED width, not the viewport, so a chart squeezed
83
- * by a `PageAside` gets the short height on a desktop too. A plain number is unchanged and stays
84
- * supported ({@link resolveFrameHeight}).
80
+ * `{ base: 180, wide: 260 }` is the declarative escape from a page of fixed-height charts: the
81
+ * `regular`/`wide` steps are compared against the frame's own MEASURED width, not the viewport,
82
+ * so a chart squeezed by a `PageAside` gets the short height on a desktop too. A plain number is
83
+ * an override and stays supported ({@link resolveFrameHeight}); one that leaves the plot under
84
+ * `VX.minPlotHeight` warns once in dev.
85
85
  */
86
86
  height?: number | ResponsiveChartHeight
87
87
  /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set — which warns
@@ -136,6 +136,10 @@ export type PlotRect = {
136
136
  width: number
137
137
  height: number
138
138
  hidden: ReadonlySet<string>
139
+ /** Whether THIS frame's legend is currently portalled into a `ChartCard` header slot — a chart
140
+ * composing `ChartFrame` (e.g. `CartesianChart`'s end labels, R2C-10) reads this to skip a
141
+ * band-legend-only affordance once the header already names every series for free. */
142
+ legendInHeader: boolean
139
143
  }
140
144
 
141
145
  const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
@@ -219,6 +223,66 @@ export function ChartFrame({
219
223
  }: ChartFrameProps): ReactNode {
220
224
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
221
225
  const { ref: legendRef, width: legendW, height: legendH } = useChartSize()
226
+ const { ref: slotRef, width: slotMeasuredW } = useChartSize()
227
+ const {
228
+ inCard,
229
+ legendSlot,
230
+ short: cardShort,
231
+ legendClaimants,
232
+ dispatchLegendSlot,
233
+ } = useContext(ChartCardContext)
234
+ const frameId = useId()
235
+ const [slotSeedW, setSlotSeedW] = useState(0)
236
+ const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
237
+ const vertical = placement === 'left' || placement === 'right'
238
+ // `Donut` sets `headerSlot: false` (P1-4) — it never wants the slot, so it must never CLAIM it
239
+ // either, or it would silently deny a sibling frame in the same card that does want it. A
240
+ // vertical (left/right) legend never portals into the header either (`inHeader` below requires
241
+ // `!vertical`), so it must not claim the slot from a sibling that could actually use it.
242
+ const wantsHeaderSlot =
243
+ legendSlot !== null && legend !== false && legend.headerSlot !== false && !vertical
244
+ // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
245
+ // legend naming a series with nothing to point at is its own small lie.
246
+ const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
247
+ const legendVisible = legend !== false && resolvedState === null
248
+ // Ownership is DERIVED from the card's claimant queue (`legendSlotReducer`), never mirrored here:
249
+ // the first frame to claim owns the slot, a later one is denied until everything ahead of it
250
+ // releases, and each frame's only job is to claim while it wants the slot and release on the way
251
+ // out (P2-9).
252
+ //
253
+ // Denied means THIS frame's own claim is in the queue and is not its head — never "someone else
254
+ // owns it" read before this frame has claimed. A new frame's first render can still see an
255
+ // owner that is unmounting in the same commit (a tab switch, a different `useId`); that is a
256
+ // hand-off, and warning off that stale head reported contention for a frame about to own it.
257
+ const ownsSlot = wantsHeaderSlot && legendClaimants[0] === frameId
258
+ const slotDenied = wantsHeaderSlot && !ownsSlot && legendClaimants.includes(frameId)
259
+ useLayoutEffect(() => {
260
+ if (!wantsHeaderSlot) return undefined
261
+ dispatchLegendSlot({ type: 'claim', id: frameId })
262
+ return () => dispatchLegendSlot({ type: 'release', id: frameId })
263
+ }, [wantsHeaderSlot, frameId, dispatchLegendSlot])
264
+ useLayoutEffect(() => {
265
+ if (slotDenied && legendVisible) reportOnce('ChartFrame', legendSlotContention('ChartFrame'))
266
+ }, [slotDenied, legendVisible])
267
+ // Only the owner observes the slot — its width is what a header legend fits. Seeded synchronously
268
+ // (P2-8) so the first paint already knows the slot's real width, instead of assuming 0 until the
269
+ // ResizeObserver's first (asynchronous) callback — which is what let a header legend paint once in
270
+ // the band before jumping to the header on every mount.
271
+ useLayoutEffect(() => {
272
+ if (!ownsSlot || legendSlot === null) return undefined
273
+ setSlotSeedW(legendSlot.getBoundingClientRect().width)
274
+ slotRef(legendSlot)
275
+ return () => {
276
+ slotRef(null)
277
+ setSlotSeedW(0)
278
+ }
279
+ }, [ownsSlot, legendSlot, slotRef])
280
+ const viewportClass = useSizeClass()
281
+ // No `BasaltProvider` (a charts-only consumer) means no viewport hint: the unmeasured first frame
282
+ // resolves to the regular (desktop) class, not phone chrome.
283
+ const sizeClass = useContext(SizeClassHintContext) === undefined ? 'medium' : viewportClass
284
+ const coarse = useCoarsePointer()
285
+ const viewportH = useViewportHeight()
222
286
  const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
223
287
 
224
288
  const toggleKey = useCallback((key: string) => {
@@ -255,25 +319,90 @@ export function ChartFrame({
255
319
  ],
256
320
  [fill, height, aspectRatio],
257
321
  )
322
+ const legendItems = legend === false ? [] : deriveLegend(series)
323
+ useValidateProps(
324
+ 'ChartFrame',
325
+ () =>
326
+ // A cap covering every entry rolls nothing up — it is how `Donut` states "never roll up".
327
+ legend !== false && legend.maxRows !== undefined && legend.maxRows < legendItems.length
328
+ ? deprecatedProp(
329
+ 'ChartFrame',
330
+ 'legend.maxRows',
331
+ 'nothing (the legend fits by measured width)',
332
+ '1.32.0',
333
+ `Its UNIT changed from rows to entries: \`maxRows: ${legend.maxRows}\` is now read as a cap of ${legend.maxRows} legend ENTRIES (not rows), with the rest folded into an \`All N\` disclosure. An explicit value still wins this release.`,
334
+ )
335
+ : null,
336
+ [legend, legendItems.length],
337
+ )
338
+ const statedHeight =
339
+ height === undefined
340
+ ? undefined
341
+ : resolveFrameHeight(height, resolveFrameClass({ frameW: containerW, sizeClass }))
342
+ // Memoized on scalars (the legend by its keys+labels) so `ChartTierProvider`'s value is stable
343
+ // across renders that change nothing. No slot, not the owner, or not measured yet → `slotW` 0 →
344
+ // a band. `slotSeedW` (P2-8) covers the gap before the ResizeObserver's own first callback.
345
+ const slotW = ownsSlot ? Math.max(slotMeasuredW, slotSeedW) : 0
346
+ const groups = legend !== false && legend.groups === true
347
+ // Role and note both feed the resolver (`orderEntries`'s grouping, `legendEntryWidth`'s measured
348
+ // fit), so a change to either — a series moving `role`, a note appearing/disappearing — has to
349
+ // invalidate this memo exactly like a key or label change does; keying on key+label alone missed
350
+ // both and served a stale layout until something else happened to change first.
351
+ const legendKey = legendItems
352
+ .map((item) => `${item.key}\u0000${item.label}\u0000${item.role ?? ''}\u0000${item.note ?? ''}`)
353
+ .join('\u0001')
354
+ const layout = useMemo(
355
+ () =>
356
+ resolveChartLayout({
357
+ frameW: containerW,
358
+ slotW,
359
+ viewportH,
360
+ legendItems,
361
+ sizeClass,
362
+ coarse,
363
+ cardShort,
364
+ groups,
365
+ ...(statedHeight !== undefined && { override: { height: statedHeight } }),
366
+ }),
367
+ // `legendItems` is a fresh array per render; `legendKey` is its identity.
368
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
369
+ [containerW, slotW, viewportH, legendKey, sizeClass, coarse, cardShort, groups, statedHeight],
370
+ )
258
371
 
259
- const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
260
- const vertical = placement === 'left' || placement === 'right'
261
- // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
262
- // legend naming a series with nothing to point at is its own small lie.
263
- const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
264
- const legendVisible = legend !== false && resolvedState === null
265
- const tier = resolveChartTier(containerW)
372
+ // The resolver gates every placement: none at micro, else the card's header slot when there is
373
+ // one AND this frame owns it (P2-9) — `slotW` is already 0 for a non-owner or a `headerSlot:
374
+ // false` kind (P1-4), so `fit.where` already reads 'band' for both; `ownsSlot` here is only the
375
+ // (harmless) belt to that suspenders.
376
+ const fit = layout.legend.mode === 'dots' || layout.legend.mode === 'chips' ? layout.legend : null
377
+ const legendMode = fit?.mode
378
+ const showLegend = legendVisible && fit !== null
379
+ const inHeader = showLegend && ownsSlot && fit?.where === 'header'
380
+ const sideLegendWidth = showLegend && vertical ? legendW : 0
381
+ const topBottomLegendHeight = showLegend && !vertical && !inHeader ? legendH : 0
266
382
 
383
+ const computedHeight =
384
+ aspectRatio !== undefined ? Math.round(containerW / aspectRatio) : layout.height
385
+ // In a card the computed height is a floor: a stretched grid row hands the body more, and the
386
+ // frame (flex 1 in the body) takes it. Never below what the legend band plus the plot floor need.
387
+ const grows = inCard && !fill
267
388
  const resolvedHeight = fill
268
389
  ? containerH
269
- : aspectRatio !== undefined
270
- ? Math.round(containerW / aspectRatio)
271
- : resolveFrameHeight(height ?? DEFAULT_HEIGHT, containerW)
272
-
273
- const sideLegendWidth = legendVisible && vertical ? legendW : 0
274
- const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
390
+ : grows
391
+ ? Math.max(computedHeight, containerH)
392
+ : computedHeight
393
+ const cardMinHeight = Math.max(computedHeight, VX.minPlotHeight + topBottomLegendHeight)
275
394
 
276
- const legendItems = legend === false ? [] : deriveLegend(series)
395
+ useValidateProps(
396
+ 'ChartFrame',
397
+ () =>
398
+ typeof height === 'number' &&
399
+ !fill &&
400
+ aspectRatio === undefined &&
401
+ height - topBottomLegendHeight < VX.minPlotHeight
402
+ ? plotBelowFloor('ChartFrame', height, VX.minPlotHeight)
403
+ : null,
404
+ [height, fill, aspectRatio, topBottomLegendHeight],
405
+ )
277
406
  const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
278
407
 
279
408
  // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
@@ -283,8 +412,8 @@ export function ChartFrame({
283
412
  // plot pays for. Top/bottom only: a side legend costs width, not height.
284
413
  const { maxRows, legendWins } = resolveLegendRollup({
285
414
  ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
286
- tier,
287
- fillBand: legendVisible && fill && !vertical,
415
+ ...(!vertical && fit !== null && fit.overflow > 0 && { fittedMaxRows: fit.visible }),
416
+ fillBand: showLegend && fill && !inHeader && !vertical,
288
417
  items: legendItems,
289
418
  containerW,
290
419
  available: resolvedHeight - VX.minPlotHeight,
@@ -300,7 +429,7 @@ export function ChartFrame({
300
429
  })
301
430
 
302
431
  const legendNode =
303
- legend === false || resolvedState !== null ? null : (
432
+ legend === false || resolvedState !== null || !showLegend ? null : (
304
433
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
305
434
  <ChartLegend
306
435
  items={legendItems}
@@ -310,6 +439,7 @@ export function ChartFrame({
310
439
  {...(chartId !== undefined && { chartId })}
311
440
  {...(legend.groups !== undefined && { groups: legend.groups })}
312
441
  {...(maxRows !== undefined && { maxRows })}
442
+ {...(legendMode !== undefined && { mode: legendMode })}
313
443
  {...(legend.highlighted !== undefined && { highlighted: legend.highlighted })}
314
444
  {...(legend.onHighlight !== undefined && { onHighlight: legend.onHighlight })}
315
445
  />
@@ -317,15 +447,26 @@ export function ChartFrame({
317
447
  )
318
448
 
319
449
  return (
320
- <ChartTierProvider tier={tier}>
450
+ <ChartTierProvider containerClass={layout.containerClass}>
321
451
  <div
322
452
  ref={containerRef}
323
453
  {...(className !== undefined && { className })}
324
- style={{ ...outerStyle(fill, vertical), ...style }}
454
+ style={{
455
+ ...outerStyle(fill, vertical),
456
+ ...(grows && { flex: '1 1 0', minHeight: cardMinHeight }),
457
+ ...style,
458
+ }}
325
459
  {...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
326
460
  {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
327
461
  >
328
- {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
462
+ {inHeader &&
463
+ legendNode !== null &&
464
+ legendSlot !== null &&
465
+ createPortal(legendNode, legendSlot)}
466
+ {!inHeader &&
467
+ legendNode !== null &&
468
+ (placement === 'top' || placement === 'left') &&
469
+ legendNode}
329
470
  {plot.width > 0 &&
330
471
  plot.height > 0 &&
331
472
  (resolvedState === 'pending' ? (
@@ -339,9 +480,12 @@ export function ChartFrame({
339
480
  {...(typeof state?.empty === 'string' && { label: state.empty })}
340
481
  />
341
482
  ) : (
342
- children({ ...plot, hidden })
483
+ children({ ...plot, hidden, legendInHeader: inHeader })
343
484
  ))}
344
- {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
485
+ {!inHeader &&
486
+ legendNode !== null &&
487
+ (placement === 'bottom' || placement === 'right') &&
488
+ legendNode}
345
489
  </div>
346
490
  </ChartTierProvider>
347
491
  )