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
@@ -1,9 +1,10 @@
1
- import type { CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react'
2
- import { useState } from 'react'
1
+ import type { CSSProperties, MouseEvent, ReactNode } from 'react'
3
2
  import type { BasaltProps } from '../../common/props'
4
3
  import { alpha, VX } from '../../tokens'
5
4
  import type { SeriesRole, LegendPlacement } from '../series'
6
- import { useChartTierMetrics } from './chart-tier'
5
+ import { DOTS_HIT_GAP, LEGEND_DOT_SIZE, LEGEND_ROW_GAP, orderEntries } from './chart-frame-layout'
6
+ import { useChartMetrics, useCoarsePointer } from './chart-tier'
7
+ import { LegendDisclosure, useLegendDisclosure } from './legend-disclosure'
7
8
 
8
9
  export type LegendEntry = {
9
10
  key: string
@@ -51,43 +52,28 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
51
52
  textAlign: 'inherit',
52
53
  }
53
54
 
54
- const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
55
-
56
55
  /** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
57
56
  const NO_HIDDEN: ReadonlySet<string> = new Set()
58
57
 
59
- const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
60
-
61
- /** Order entries series → overlay → reference and record where a divider belongs. */
62
- function orderEntries(
63
- items: LegendEntry[],
64
- groups: boolean,
65
- ): { entries: LegendEntry[]; dividerAfter: Set<number> } {
66
- if (!groups) return { entries: items, dividerAfter: new Set() }
67
-
68
- const dividerAfter = new Set<number>()
69
- const entries: LegendEntry[] = []
70
- const nonEmptyGroups = GROUP_ORDER.map((role) =>
71
- items.filter((item) => roleOf(item) === role),
72
- ).filter((group) => group.length > 0)
73
- nonEmptyGroups.forEach((group, i) => {
74
- entries.push(...group)
75
- if (i < nonEmptyGroups.length - 1) dividerAfter.add(entries.length - 1)
76
- })
77
- return { entries, dividerAfter }
78
- }
79
-
80
- function wrapperStyle(placement: LegendPlacement, fontSize: number): CSSProperties {
58
+ function wrapperStyle(placement: LegendPlacement, fontSize: number, dots: boolean): CSSProperties {
81
59
  const vertical = placement === 'left' || placement === 'right'
60
+ const gap = dots ? DOTS_HIT_GAP : VX.legendGap
82
61
  return {
83
62
  display: 'flex',
84
63
  flexDirection: vertical ? 'column' : 'row',
85
64
  flexWrap: vertical ? 'nowrap' : 'wrap',
86
65
  alignItems: vertical ? 'flex-start' : 'center',
87
- justifyContent: vertical ? 'flex-start' : 'center',
88
- columnGap: VX.legendGap,
89
- rowGap: VX.legendGap,
90
- padding: '8px 0 2px',
66
+ justifyContent: 'flex-start',
67
+ columnGap: gap,
68
+ // Always the tight row gap (R2C-9): the wider dots COLUMN gap is a hit-overlap cap, not
69
+ // breathing room between wrapped rows — a dots-mode header legend is 1 row by law
70
+ // (`LEGEND_ROWS.header` in `chart-layout.ts`) anyway, so this only ever shows in the band.
71
+ rowGap: LEGEND_ROW_GAP,
72
+ // Caps each entry's `[data-basalt-hit]` overlay at the real inter-entry gap (styles.css).
73
+ ...({ '--vx-hit-gap': `${gap}px` } as CSSProperties),
74
+ // A dots-mode row has no text baseline to breathe around — the header-fold law (wave 8) is
75
+ // the reason this row exists at all, so it gets the same tight treatment.
76
+ padding: dots ? '4px 0 2px' : '8px 0 2px',
91
77
  fontSize,
92
78
  color: VX.muted,
93
79
  }
@@ -205,6 +191,27 @@ const LEGEND_CHILD_STYLE: CSSProperties = {
205
191
  opacity: 0.75,
206
192
  }
207
193
 
194
+ /**
195
+ * The whole entry collapses to its color, in 'dots' mode (`docs/CHARTS-SPEC.md` §5: a short or
196
+ * `compact`-container header has no room for a text label per entry) — a plain 8px circle
197
+ * regardless of the series' own shape, since a two-tone `split`/`splitLine` swatch cannot express
198
+ * two colors in one dot. The name survives via the button's own `aria-label`; sighted users get
199
+ * color-coded identity only, same trade the `compact` container class already made for the y axis.
200
+ */
201
+ function LegendDot({ item }: { item: LegendEntry }) {
202
+ return (
203
+ <span
204
+ style={{
205
+ width: LEGEND_DOT_SIZE,
206
+ height: LEGEND_DOT_SIZE,
207
+ borderRadius: '50%', // theme-allow raw-surface — a circle shape, not a surface corner
208
+ backgroundColor: item.color,
209
+ flexShrink: 0,
210
+ }}
211
+ />
212
+ )
213
+ }
214
+
208
215
  /** Compact swatch for a folded child entry — smaller than {@link LegendSwatch}, line/bar only
209
216
  * (folded companions are simple line or bar series; `split`/`splitLine` never fold). */
210
217
  function LegendChildSwatch({ item }: { item: LegendEntry }) {
@@ -256,24 +263,19 @@ function LegendChild({ item }: { item: LegendEntry }) {
256
263
  * static legends without hover interactivity. `chartId` namespaces `split` clipPath ids so two
257
264
  * legends on the same page never collide. `groups` renders series → hairline divider → overlay →
258
265
  * reference bands instead of one flat smear. `maxRows` caps the number of rendered ENTRIES and
259
- * rolls the remainder into a `+N more` chip — it is an entry count, not a row count, in spite of
260
- * the name (`entries.slice(0, cap)` below is the whole of it, and `ChartFrame` measures rows for a
261
- * different purpose in `legendEntryCap`). The name is inherited and reaches the public
262
- * `ChartTierMetrics.legendMaxRows`, so it stays until a minor can rename both.
266
+ * rolls the remainder into an `All N` disclosure chip — it is an entry count, not a row count, in
267
+ * spite of the name (`entries.slice(0, cap)` below is the whole of it). `ChartFrame` derives the
268
+ * cap from the measured fit (`resolveChartLayout().legend`); a caller-stated value wins.
263
269
  *
264
270
  * `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
265
271
  * and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
266
- * §5).
272
+ * §5). A hidden entry is announced via `aria-pressed`.
267
273
  *
268
- * The label font and the DEFAULT entry cap both track the ambient chart tier (§8): inside a
269
- * phone-width `ChartFrame` the legend drops one type step and rolls up past two entries. An
270
- * explicit `maxRows` always wins. Omit it for a static legend; a hidden entry is announced via `aria-pressed`.
271
- *
272
- * **The rollup is a disclosure, not a label.** A cap of two leaves six of eight plotted colours
273
- * unnamed, and a `+6 more` that is a `<span>` gives a phone reader no way to learn them — a
274
- * categorical encoding the chart draws and refuses to decode. The chip is a `<button>` carrying
275
- * `aria-expanded`: expanded, every entry renders, the legend band grows, and `ChartFrame`'s
276
- * observer re-flows the frame around it (the plot keeps `VX.minPlotHeight`).
274
+ * **The rollup is a disclosure, not a label.** `All N` is a `<button>` carrying `aria-expanded`
275
+ * that opens EVERY entry, toggles included, in a popover under the chip on a fine pointer and a
276
+ * bottom sheet on a coarse one. Both are fixed-position and portalled, so opening one never grows
277
+ * the legend band or moves the plot. Escape, an outside press, a scroll or a resize closes it, and
278
+ * focus returns to the chip on every close.
277
279
  */
278
280
  export function ChartLegend({
279
281
  items,
@@ -285,6 +287,7 @@ export function ChartLegend({
285
287
  placement = 'bottom',
286
288
  groups = false,
287
289
  maxRows,
290
+ mode = 'chips',
288
291
  className,
289
292
  style,
290
293
  }: BasaltProps & {
@@ -299,40 +302,55 @@ export function ChartLegend({
299
302
  placement?: LegendPlacement
300
303
  groups?: boolean
301
304
  maxRows?: number
305
+ /**
306
+ * `'dots'` collapses every VISIBLE entry to a plain color dot with no label (the name still
307
+ * reaches assistive tech via `aria-label`) — `resolveChartLayout`'s header-slot legend forces
308
+ * this for a short `ChartCard` (wave 11, `docs/CHARTS-SPEC.md` §5) so the legend band's height and
309
+ * width both shrink before it eats plot space. The `All N` disclosure panel is unaffected: it
310
+ * always shows the full swatch + label, `mode` only governs the inline row. Default `'chips'`
311
+ * (today's swatch + label rendering) for every other caller.
312
+ */
313
+ mode?: 'dots' | 'chips'
302
314
  }) {
303
- const tier = useChartTierMetrics()
304
- const [expanded, setExpanded] = useState(false)
305
- const handleEnter = (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLButtonElement>) => {
306
- const key = e.currentTarget.dataset['legendKey']
307
- if (key !== undefined) onHighlight?.(key)
308
- }
315
+ const tier = useChartMetrics()
316
+ const coarse = useCoarsePointer()
317
+ const { open, panelId, openFrom, close, panel } = useLegendDisclosure(coarse)
318
+
319
+ const handleEnter = (key: string) => onHighlight?.(key)
309
320
  const handleLeave = () => onHighlight?.(null)
310
- const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
311
- const key = e.currentTarget.dataset['legendKey']
312
- if (key !== undefined) onToggle?.(key)
313
- }
314
321
 
315
322
  const idPrefix = chartId ? `${chartId}-` : ''
316
323
  const vertical = placement === 'left' || placement === 'right'
317
324
  const { entries, dividerAfter } = orderEntries(items, groups)
318
- const cap = maxRows ?? tier.legendMaxRows
319
- const rolledUp = cap === undefined ? 0 : Math.max(entries.length - cap, 0)
320
- const visible = rolledUp > 0 && !expanded ? entries.slice(0, cap) : entries
325
+ const rolledUp = maxRows === undefined ? 0 : Math.max(entries.length - maxRows, 0)
326
+ const visible = rolledUp > 0 ? entries.slice(0, maxRows) : entries
321
327
 
322
- const nodes: ReactNode[] = []
323
- visible.forEach((item, i) => {
324
- nodes.push(
328
+ // The disclosure panel is a second copy of the same entries: it gets its own id namespace and
329
+ // no `data-legend-key`, so a page query for the band's entries never finds the panel's twins.
330
+ const entryButton = (item: LegendEntry, inPanel = false): ReactNode => {
331
+ // A dots-mode entry names nothing on its own face — its whole point is the color — so a tap
332
+ // opens the same All-N sheet the overflow chip does, rather than silently hiding a series the
333
+ // reader was trying to identify (R2C-8: "what is this?" used to toggle it off instead). Gated
334
+ // on `mode` alone: with `legend.toggle: false` (no `onToggle`) a dots entry still has no OTHER
335
+ // way to name itself, so it still needs the title + disclosure — `onToggle !== undefined` used
336
+ // to suppress both just because toggling happened to be off.
337
+ const isDotTrigger = !inPanel && mode === 'dots'
338
+ return (
325
339
  <button
326
340
  key={item.key}
327
341
  type="button"
328
- data-legend-key={item.key}
342
+ data-basalt-hit
343
+ {...(!inPanel && { 'data-legend-key': item.key })}
329
344
  // The note is the whole point for a series that is invisible in the plot, so it has to
330
345
  // reach a screen reader too — an explicit aria-label would otherwise replace it.
331
346
  aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
332
- {...(onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
347
+ {...(isDotTrigger && { title: item.label, 'aria-haspopup': 'dialog' as const })}
348
+ {...(isDotTrigger
349
+ ? { 'aria-expanded': open, 'aria-controls': panelId }
350
+ : onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
333
351
  style={{
334
352
  ...LEGEND_ITEM_BUTTON,
335
- cursor: onToggle === undefined ? 'default' : 'pointer',
353
+ cursor: onToggle === undefined && !isDotTrigger ? 'default' : 'pointer',
336
354
  // Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
337
355
  // it is the one being hovered.
338
356
  opacity: hidden.has(item.key)
@@ -342,20 +360,33 @@ export function ChartLegend({
342
360
  : 0.3,
343
361
  textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
344
362
  }}
345
- {...(onToggle !== undefined && { onClick: handleToggle })}
346
- onMouseEnter={handleEnter}
363
+ {...(isDotTrigger
364
+ ? { onClick: (e: MouseEvent<HTMLButtonElement>) => openFrom(e.currentTarget) }
365
+ : onToggle !== undefined && { onClick: () => onToggle(item.key) })}
366
+ onMouseEnter={() => handleEnter(item.key)}
347
367
  onMouseLeave={handleLeave}
348
- onFocus={handleEnter}
368
+ onFocus={() => handleEnter(item.key)}
349
369
  onBlur={handleLeave}
350
370
  >
351
- <LegendSwatch item={item} idPrefix={idPrefix} />
352
- <span>{item.label}</span>
353
- {item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
354
- {item.children?.map((child) => (
355
- <LegendChild key={child.key} item={child} />
356
- ))}
357
- </button>,
371
+ {!inPanel && mode === 'dots' ? (
372
+ <LegendDot item={item} />
373
+ ) : (
374
+ <>
375
+ <LegendSwatch item={item} idPrefix={inPanel ? `${idPrefix}disclosure-` : idPrefix} />
376
+ <span>{item.label}</span>
377
+ {item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
378
+ {item.children?.map((child) => (
379
+ <LegendChild key={child.key} item={child} />
380
+ ))}
381
+ </>
382
+ )}
383
+ </button>
358
384
  )
385
+ }
386
+
387
+ const nodes: ReactNode[] = []
388
+ visible.forEach((item, i) => {
389
+ nodes.push(entryButton(item))
359
390
  if (dividerAfter.has(i))
360
391
  nodes.push(<LegendDivider key={`divider-${item.key}`} columnLayout={vertical} />)
361
392
  })
@@ -364,11 +395,14 @@ export function ChartLegend({
364
395
  <button
365
396
  key="legend-more"
366
397
  type="button"
367
- aria-expanded={expanded}
368
- onClick={() => setExpanded((prev) => !prev)}
398
+ data-basalt-hit
399
+ aria-expanded={open}
400
+ aria-haspopup="dialog"
401
+ aria-controls={panelId}
402
+ onClick={(e) => (open ? close() : openFrom(e.currentTarget))}
369
403
  style={{ ...LEGEND_ITEM_BUTTON, cursor: 'pointer', textDecoration: 'underline' }}
370
404
  >
371
- {expanded ? 'Show less' : `+${rolledUp} more`}
405
+ {`All ${entries.length}`}
372
406
  </button>,
373
407
  )
374
408
  }
@@ -376,9 +410,12 @@ export function ChartLegend({
376
410
  return (
377
411
  <div
378
412
  {...(className !== undefined && { className })}
379
- style={{ ...wrapperStyle(placement, tier.legendFontSize), ...style }}
413
+ style={{ ...wrapperStyle(placement, tier.legendFontSize, mode === 'dots'), ...style }}
380
414
  >
381
415
  {nodes}
416
+ <LegendDisclosure {...panel} fontSize={tier.legendFontSize}>
417
+ {entries.map((item) => entryButton(item, true))}
418
+ </LegendDisclosure>
382
419
  </div>
383
420
  )
384
421
  }
@@ -2,8 +2,9 @@ import type { CSSProperties, ReactNode } from 'react'
2
2
  import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { VX } from '../../tokens'
5
+ import type { TooltipRowData } from '../series'
5
6
  import { fmtTooltipDate } from '../utils/format'
6
- import { useChartTierMetrics } from './chart-tier'
7
+ import { useChartMetrics } from './chart-tier'
7
8
 
8
9
  // Panel bg + shadow-card, radius 8 (docs/DESIGN-SPEC.md §5's "Tooltip/popover/menu" idiom) — the
9
10
  // same depth-via-shadow treatment as ChartCard, never a `border` property. Surfaces resolve per
@@ -11,7 +12,7 @@ import { useChartTierMetrics } from './chart-tier'
11
12
  const TOOLTIP_STYLES: CSSProperties = {
12
13
  position: 'fixed',
13
14
  pointerEvents: 'none',
14
- zIndex: 9999,
15
+ zIndex: VX.zIndexFloating,
15
16
  backgroundColor: VX.surface.panel,
16
17
  borderRadius: 'var(--vx-radius-floating)',
17
18
  padding: '0',
@@ -136,6 +137,21 @@ export function TooltipRow({
136
137
  )
137
138
  }
138
139
 
140
+ /** The derived rows (`deriveTooltipRows`), one {@link TooltipRow} each. */
141
+ export function TooltipRows({ rows }: { rows: readonly TooltipRowData[] }) {
142
+ return rows.map((row) => (
143
+ <TooltipRow
144
+ key={row.key}
145
+ color={row.color}
146
+ label={row.label}
147
+ value={row.value}
148
+ shape={row.shape}
149
+ dashed={row.dashed}
150
+ {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
151
+ />
152
+ ))
153
+ }
154
+
139
155
  export function TooltipBody({ children }: { children: ReactNode }) {
140
156
  return <div style={{ padding: '5px 0' }}>{children}</div>
141
157
  }
@@ -191,7 +207,7 @@ export function ChartTooltipFloat({
191
207
  const ref = useRef<HTMLDivElement>(null)
192
208
  const [box, setBox] = useState<{ width: number; height: number } | null>(null)
193
209
  const isClient = useIsClient()
194
- const { tooltipMinWidth } = useChartTierMetrics()
210
+ const { tooltipMinWidth } = useChartMetrics()
195
211
 
196
212
  useLayoutEffect(() => {
197
213
  const el = ref.current
@@ -215,6 +231,11 @@ export function ChartTooltipFloat({
215
231
  const vw = window.innerWidth
216
232
  const vh = window.innerHeight
217
233
 
234
+ // An anchor outside the viewport means the chart that owns it has scrolled off-screen. Clamping
235
+ // would paint a follower's box over unrelated chrome (the bottom nav — `R2C-3`); render nothing
236
+ // instead.
237
+ if (anchor.y < 0 || anchor.y > vh) return null
238
+
218
239
  const flipsLeft = anchor.x + TOOLTIP_GAP + width + VIEWPORT_MARGIN > vw
219
240
  const left = flipsLeft
220
241
  ? Math.max(VIEWPORT_MARGIN, anchor.x - TOOLTIP_GAP - width)
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { VX } from '../../tokens'
3
- import { useChartTierMetrics } from './chart-tier'
3
+ import { useChartMetrics } from './chart-tier'
4
4
 
5
5
  /**
6
6
  * The shared vertical hover crosshair — one implementation for what used to be five hand-rolled
@@ -38,6 +38,6 @@ export function SeriesDot({
38
38
  color: string
39
39
  r?: number
40
40
  }): ReactNode {
41
- const { dotR } = useChartTierMetrics()
41
+ const { dotR } = useChartMetrics()
42
42
  return <circle cx={cx} cy={cy} r={r ?? dotR} fill={color} stroke={VX.dotStroke} strokeWidth={2} />
43
43
  }
@@ -0,0 +1,13 @@
1
+ /*
2
+ * `HoverOverlay` — the transparent plot rect every cartesian kind scrubs through. A tap focuses it
3
+ * on pointerdown, so the UA's own outline would ring the whole plot on every phone tap (`R2C-15`);
4
+ * keyboard focus keeps a VX accent inset instead.
5
+ */
6
+ .overlay {
7
+ outline: none;
8
+ }
9
+
10
+ .overlay:focus-visible {
11
+ outline: 1px solid var(--vx-accent);
12
+ outline-offset: -1px;
13
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Typed CSS-module declaration — named string props, so dot access stays verbatim under the
3
+ * package's strict flags.
4
+ */
5
+ declare const classes: {
6
+ readonly overlay: string
7
+ }
8
+ export default classes
@@ -1,4 +1,29 @@
1
1
  import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
2
+ import type { ChartCursor } from '../hooks/useChartCursor'
3
+ import styles from './HoverOverlay.module.css'
4
+
5
+ /** Props of {@link HoverOverlay}. */
6
+ export type HoverOverlayProps = {
7
+ width: number
8
+ height: number
9
+ onMove: PointerEventHandler<SVGRectElement>
10
+ /** Coarse-pointer tap: resolves + shows the tooltip immediately, with no preceding `pointermove`
11
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip). The press is provisional; `onUp` commits it. */
12
+ onDown?: PointerEventHandler<SVGRectElement>
13
+ /** Commits the provisional touch press so the pin survives the lift. */
14
+ onUp?: PointerEventHandler<SVGRectElement>
15
+ onLeave: PointerEventHandler<SVGRectElement>
16
+ /** Present = the overlay is focusable and scrubs on ←/→. */
17
+ onKeyDown?: KeyboardEventHandler<SVGRectElement>
18
+ onBlur?: FocusEventHandler<SVGRectElement>
19
+ ariaLabel?: string
20
+ /** Index of the focused point — announced as the slider position. */
21
+ valueNow?: number
22
+ /** Last index of the domain. */
23
+ valueMax?: number
24
+ /** Human-readable label for the focused point (the formatted x key). */
25
+ valueText?: string
26
+ }
2
27
 
3
28
  /**
4
29
  * Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
@@ -11,6 +36,8 @@ export function HoverOverlay({
11
36
  width,
12
37
  height,
13
38
  onMove,
39
+ onDown,
40
+ onUp,
14
41
  onLeave,
15
42
  onKeyDown,
16
43
  onBlur,
@@ -18,24 +45,14 @@ export function HoverOverlay({
18
45
  valueNow,
19
46
  valueMax,
20
47
  valueText,
21
- }: {
22
- width: number
23
- height: number
24
- onMove: PointerEventHandler<SVGRectElement>
25
- onLeave: PointerEventHandler<SVGRectElement>
26
- /** Present = the overlay is focusable and scrubs on ←/→. */
27
- onKeyDown?: KeyboardEventHandler<SVGRectElement>
28
- onBlur?: FocusEventHandler<SVGRectElement>
29
- ariaLabel?: string
30
- /** Index of the focused point — announced as the slider position. */
31
- valueNow?: number
32
- /** Last index of the domain. */
33
- valueMax?: number
34
- /** Human-readable label for the focused point (the formatted x key). */
35
- valueText?: string
36
- }) {
48
+ }: HoverOverlayProps) {
37
49
  return (
50
+ // `touch-action: pan-y` lives on the plot's `<svg>` (each kind's own), NOT here: Chrome ignores
51
+ // `touch-action` on an SVG child `<rect>`, so the browser claims a horizontal pan and cancels
52
+ // the pointer (`R2C-2`). The `overlay` class owns the focus ring: no UA outline on tap, a VX
53
+ // accent inset under `:focus-visible` (`R2C-15`).
38
54
  <rect
55
+ className={styles.overlay}
39
56
  width={width}
40
57
  height={height}
41
58
  fill="transparent"
@@ -54,13 +71,38 @@ export function HoverOverlay({
54
71
  onKeyDown,
55
72
  ...(onBlur !== undefined && { onBlur }),
56
73
  })}
57
- // `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
58
- // phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
59
- // reaches this overlay to scrub the chart.
60
- style={{ touchAction: 'pan-y' }}
61
74
  onPointerMove={onMove}
75
+ {...(onDown !== undefined && { onPointerDown: onDown })}
76
+ {...(onUp !== undefined && { onPointerUp: onUp })}
62
77
  onPointerLeave={onLeave}
63
78
  onPointerCancel={onLeave}
64
79
  />
65
80
  )
66
81
  }
82
+
83
+ /**
84
+ * Everything a FOCUSABLE overlay over indexed data takes besides its rect: the cursor's pointer and
85
+ * keyboard handlers plus the slider label/position. `CartesianChart`, `DualPanel`'s top pane and
86
+ * the banded kinds all wire it identically, so it is stated once.
87
+ */
88
+ export function cursorSliderProps<T>(input: {
89
+ cursor: ChartCursor<T>
90
+ data: readonly T[]
91
+ getX: (d: T) => string
92
+ formatX: (key: string) => string
93
+ ariaLabel: string | undefined
94
+ }): Omit<HoverOverlayProps, 'width' | 'height'> {
95
+ const { cursor, data, getX, formatX, ariaLabel } = input
96
+ const { point } = cursor
97
+ return {
98
+ onMove: cursor.onPointerMove,
99
+ onDown: cursor.onPointerDown,
100
+ onUp: cursor.onPointerUp,
101
+ onLeave: cursor.onPointerLeave,
102
+ onKeyDown: cursor.onKeyDown,
103
+ onBlur: cursor.onBlur,
104
+ valueMax: Math.max(data.length - 1, 0),
105
+ ...(point !== null && { valueNow: data.indexOf(point), valueText: formatX(getX(point)) }),
106
+ ...(ariaLabel !== undefined && { ariaLabel }),
107
+ }
108
+ }
@@ -0,0 +1,46 @@
1
+ import { createContext } from 'react'
2
+ import type { Dispatch } from 'react'
3
+
4
+ /** One frame claiming or releasing the card's header legend slot. */
5
+ export type LegendSlotAction = { type: 'claim' | 'release'; id: string }
6
+
7
+ /**
8
+ * The ONE owner of the legend-slot protocol (`docs/waves/PLAN.md` wave 3, P2-9): claimants in claim
9
+ * order, and the head owns the slot. A later claimant is denied simply by not being the head, and
10
+ * it takes over the moment everything ahead of it releases — no retry signal, no ownership mirrored
11
+ * in a ref. Returns the same array on a no-op so a repeated claim/release never re-renders the card.
12
+ */
13
+ export function legendSlotReducer(
14
+ claimants: readonly string[],
15
+ { type, id }: LegendSlotAction,
16
+ ): readonly string[] {
17
+ const held = claimants.includes(id)
18
+ if (type === 'claim') return held ? claimants : [...claimants, id]
19
+ return held ? claimants.filter((c) => c !== id) : claimants
20
+ }
21
+
22
+ /**
23
+ * `ChartCard`'s internal context: `inCard` (a frame inside a card treats its computed height as a
24
+ * MINIMUM and grows into the card body), the legend slot — the empty header-row box a chart will portal its legend into
25
+ * (`docs/CHARTS-SPEC.md` §5, Legend), so a card has one chrome band, not two — and `short`, the same
26
+ * `measuredHeight < CARD_SHORT_HEIGHT` flag `ChartCard` already computes for the header-fold law
27
+ * (`data-basalt-card-short`), threaded through so `ChartFrame` can fold its header-slot legend to
28
+ * 'dots' before it eats the plot floor (`docs/CHARTS-SPEC.md` §5, wave 11). `null`/`false` until the slot
29
+ * mounts, and outside any card. Deliberately not exported from any barrel.
30
+ *
31
+ * `legendClaimants` is {@link legendSlotReducer}'s queue (its head owns the slot) and
32
+ * `dispatchLegendSlot` feeds it. Outside any card there is no slot, so nothing ever claims.
33
+ */
34
+ export const ChartCardContext = createContext<{
35
+ legendSlot: HTMLDivElement | null
36
+ inCard: boolean
37
+ short: boolean
38
+ legendClaimants: readonly string[]
39
+ dispatchLegendSlot: Dispatch<LegendSlotAction>
40
+ }>({
41
+ legendSlot: null,
42
+ inCard: false,
43
+ short: false,
44
+ legendClaimants: [],
45
+ dispatchLegendSlot: () => {},
46
+ })