basalt-ui 1.30.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/MIGRATING.md +172 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +54 -41
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +145 -131
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +214 -177
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +25 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -1,9 +1,12 @@
1
- import type { CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react'
2
- import { useState } from 'react'
1
+ import type { CSSProperties, MouseEvent, ReactNode } from 'react'
2
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
3
4
  import type { BasaltProps } from '../../common/props'
4
5
  import { alpha, VX } from '../../tokens'
6
+ import { useDismissOnOutside } from '../cursor/touch-pin'
5
7
  import type { SeriesRole, LegendPlacement } from '../series'
6
- import { useChartTierMetrics } from './chart-tier'
8
+ import { DOTS_HIT_GAP, LEGEND_DOT_SIZE, LEGEND_ROW_GAP, orderEntries } from './chart-frame-layout'
9
+ import { useChartMetrics, useCoarsePointer } from './chart-tier'
7
10
 
8
11
  export type LegendEntry = {
9
12
  key: string
@@ -51,43 +54,77 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
51
54
  textAlign: 'inherit',
52
55
  }
53
56
 
54
- const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
55
-
56
57
  /** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
57
58
  const NO_HIDDEN: ReadonlySet<string> = new Set()
58
59
 
59
- const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
60
+ const DISCLOSURE_PANEL: CSSProperties = {
61
+ position: 'fixed',
62
+ zIndex: VX.zIndexFloating,
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ gap: LEGEND_ROW_GAP,
66
+ // theme-allow raw-scroll-container — a fixed floating panel of one legend, not app chrome
67
+ overflowY: 'auto',
68
+ // `<dialog open>` brings UA chrome (centring margin, border, fit-content box) this resets.
69
+ margin: 0,
70
+ border: 'none',
71
+ width: 'auto',
72
+ height: 'auto',
73
+ padding: 10,
74
+ boxSizing: 'border-box',
75
+ backgroundColor: VX.surface.overlay,
76
+ boxShadow: VX.shadowOverlay,
77
+ color: VX.muted,
78
+ borderRadius: VX.radiusCard,
79
+ }
60
80
 
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() }
81
+ /** A popover never collapses below one usable row, however little room the chip leaves. */
82
+ const DISCLOSURE_MIN_HEIGHT = 96
67
83
 
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 }
84
+ /** A popover hangs off its chip (flipping above when there is no room below); a sheet is docked. */
85
+ function disclosurePlacement(chip: DOMRect, coarse: boolean): CSSProperties {
86
+ if (coarse) {
87
+ return {
88
+ left: 0,
89
+ right: 0,
90
+ bottom: 0,
91
+ maxHeight: '60dvh',
92
+ borderBottomLeftRadius: 0,
93
+ borderBottomRightRadius: 0,
94
+ padding: '12px 16px calc(12px + env(safe-area-inset-bottom, 0px))',
95
+ }
96
+ }
97
+ const below = window.innerHeight - chip.bottom
98
+ const left = Math.max(8, Math.min(chip.left, window.innerWidth - 248))
99
+ const base = { left, right: 'auto', minWidth: 200, maxWidth: 'calc(100vw - 16px)' }
100
+ return below >= 200 || below >= chip.top
101
+ ? { ...base, top: chip.bottom + 4, maxHeight: Math.max(below - 12, DISCLOSURE_MIN_HEIGHT) }
102
+ : {
103
+ ...base,
104
+ bottom: window.innerHeight - chip.top + 4,
105
+ maxHeight: Math.max(chip.top - 12, DISCLOSURE_MIN_HEIGHT),
106
+ }
78
107
  }
79
108
 
80
- function wrapperStyle(placement: LegendPlacement, fontSize: number): CSSProperties {
109
+ function wrapperStyle(placement: LegendPlacement, fontSize: number, dots: boolean): CSSProperties {
81
110
  const vertical = placement === 'left' || placement === 'right'
111
+ const gap = dots ? DOTS_HIT_GAP : VX.legendGap
82
112
  return {
83
113
  display: 'flex',
84
114
  flexDirection: vertical ? 'column' : 'row',
85
115
  flexWrap: vertical ? 'nowrap' : 'wrap',
86
116
  alignItems: vertical ? 'flex-start' : 'center',
87
- justifyContent: vertical ? 'flex-start' : 'center',
88
- columnGap: VX.legendGap,
89
- rowGap: VX.legendGap,
90
- padding: '8px 0 2px',
117
+ justifyContent: 'flex-start',
118
+ columnGap: gap,
119
+ // Always the tight row gap (R2C-9): the wider dots COLUMN gap is a hit-overlap cap, not
120
+ // breathing room between wrapped rows — a dots-mode header legend is 1 row by law
121
+ // (`LEGEND_ROWS.header` in `chart-layout.ts`) anyway, so this only ever shows in the band.
122
+ rowGap: LEGEND_ROW_GAP,
123
+ // Caps each entry's `[data-basalt-hit]` overlay at the real inter-entry gap (styles.css).
124
+ ...({ '--vx-hit-gap': `${gap}px` } as CSSProperties),
125
+ // A dots-mode row has no text baseline to breathe around — the header-fold law (wave 8) is
126
+ // the reason this row exists at all, so it gets the same tight treatment.
127
+ padding: dots ? '4px 0 2px' : '8px 0 2px',
91
128
  fontSize,
92
129
  color: VX.muted,
93
130
  }
@@ -205,6 +242,27 @@ const LEGEND_CHILD_STYLE: CSSProperties = {
205
242
  opacity: 0.75,
206
243
  }
207
244
 
245
+ /**
246
+ * The whole entry collapses to its color, in 'dots' mode (`docs/CHARTS-SPEC.md` §5: a short or
247
+ * `compact`-container header has no room for a text label per entry) — a plain 8px circle
248
+ * regardless of the series' own shape, since a two-tone `split`/`splitLine` swatch cannot express
249
+ * two colors in one dot. The name survives via the button's own `aria-label`; sighted users get
250
+ * color-coded identity only, same trade the `compact` container class already made for the y axis.
251
+ */
252
+ function LegendDot({ item }: { item: LegendEntry }) {
253
+ return (
254
+ <span
255
+ style={{
256
+ width: LEGEND_DOT_SIZE,
257
+ height: LEGEND_DOT_SIZE,
258
+ borderRadius: '50%', // theme-allow raw-surface — a circle shape, not a surface corner
259
+ backgroundColor: item.color,
260
+ flexShrink: 0,
261
+ }}
262
+ />
263
+ )
264
+ }
265
+
208
266
  /** Compact swatch for a folded child entry — smaller than {@link LegendSwatch}, line/bar only
209
267
  * (folded companions are simple line or bar series; `split`/`splitLine` never fold). */
210
268
  function LegendChildSwatch({ item }: { item: LegendEntry }) {
@@ -256,24 +314,19 @@ function LegendChild({ item }: { item: LegendEntry }) {
256
314
  * static legends without hover interactivity. `chartId` namespaces `split` clipPath ids so two
257
315
  * legends on the same page never collide. `groups` renders series → hairline divider → overlay →
258
316
  * 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.
317
+ * rolls the remainder into an `All N` disclosure chip — it is an entry count, not a row count, in
318
+ * spite of the name (`entries.slice(0, cap)` below is the whole of it). `ChartFrame` derives the
319
+ * cap from the measured fit (`resolveChartLayout().legend`); a caller-stated value wins.
263
320
  *
264
321
  * `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
265
322
  * and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
266
- * §5).
323
+ * §5). A hidden entry is announced via `aria-pressed`.
267
324
  *
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`).
325
+ * **The rollup is a disclosure, not a label.** `All N` is a `<button>` carrying `aria-expanded`
326
+ * that opens EVERY entry, toggles included, in a popover under the chip on a fine pointer and a
327
+ * bottom sheet on a coarse one. Both are fixed-position and portalled, so opening one never grows
328
+ * the legend band or moves the plot. Escape, an outside press, a scroll or a resize closes it, and
329
+ * focus returns to the chip on every close.
277
330
  */
278
331
  export function ChartLegend({
279
332
  items,
@@ -285,6 +338,7 @@ export function ChartLegend({
285
338
  placement = 'bottom',
286
339
  groups = false,
287
340
  maxRows,
341
+ mode = 'chips',
288
342
  className,
289
343
  style,
290
344
  }: BasaltProps & {
@@ -299,40 +353,119 @@ export function ChartLegend({
299
353
  placement?: LegendPlacement
300
354
  groups?: boolean
301
355
  maxRows?: number
356
+ /**
357
+ * `'dots'` collapses every VISIBLE entry to a plain color dot with no label (the name still
358
+ * reaches assistive tech via `aria-label`) — `resolveChartLayout`'s header-slot legend forces
359
+ * this for a short `ChartCard` (wave 11, `docs/CHARTS-SPEC.md` §5) so the legend band's height and
360
+ * width both shrink before it eats plot space. The `All N` disclosure panel is unaffected: it
361
+ * always shows the full swatch + label, `mode` only governs the inline row. Default `'chips'`
362
+ * (today's swatch + label rendering) for every other caller.
363
+ */
364
+ mode?: 'dots' | 'chips'
302
365
  }) {
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)
366
+ const tier = useChartMetrics()
367
+ const coarse = useCoarsePointer()
368
+ const [disclosure, setDisclosure] = useState<CSSProperties | null>(null)
369
+ // Whichever button opened the panel — the `All N` chip, or (dots mode) any entry — so focus
370
+ // returns to where the user actually tapped, not to a fixed chip that might not exist.
371
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
372
+ const panelRef = useRef<HTMLDialogElement>(null)
373
+ const open = disclosure !== null
374
+
375
+ const panelId = useId()
376
+ const wasOpen = useRef(false)
377
+
378
+ const openDisclosure = (trigger: HTMLButtonElement) => {
379
+ triggerRef.current = trigger
380
+ setDisclosure(disclosurePlacement(trigger.getBoundingClientRect(), coarse))
308
381
  }
382
+
383
+ // Focus moves into the panel on open and back to the trigger on EVERY close path.
384
+ useEffect(() => {
385
+ if (open) panelRef.current?.focus()
386
+ else if (wasOpen.current) triggerRef.current?.focus()
387
+ wasOpen.current = open
388
+ }, [open])
389
+
390
+ // The panel is fixed to where the trigger WAS — a scroll outside it (any scroller, hence capture)
391
+ // or a resize leaves it hanging off nothing; the panel's own scroll is not one. Shared with the
392
+ // chart cursors' own outside-dismiss (`useChartCursor`/`useDiscreteCursor`, see `touch-pin.ts`).
393
+ const isInsidePanel = useCallback(
394
+ (target: Node): boolean =>
395
+ panelRef.current?.contains(target) === true || triggerRef.current?.contains(target) === true,
396
+ [],
397
+ )
398
+ useDismissOnOutside({
399
+ enabled: open,
400
+ isInside: isInsidePanel,
401
+ onDismiss: () => setDisclosure(null),
402
+ dismissOnResize: true,
403
+ scrollExemptInside: true,
404
+ })
405
+
406
+ // The coarse sheet claims `aria-modal` (below), which is a claim that Tab cannot leave it — a
407
+ // native `<dialog open>` (no `showModal()`) does not enforce that on its own (B14). Wrap Tab at
408
+ // the panel's own first/last focusable rather than the fine popover, which is dismissed by an
409
+ // outside click/Escape just as easily and never claimed to be modal in the first place.
410
+ useEffect(() => {
411
+ if (!open || !coarse) return
412
+ const panel = panelRef.current
413
+ if (panel === null) return
414
+ const onKeyDown = (e: KeyboardEvent) => {
415
+ if (e.key !== 'Tab') return
416
+ const focusable = panel.querySelectorAll<HTMLElement>(
417
+ 'button, [tabindex]:not([tabindex="-1"])',
418
+ )
419
+ const first = focusable[0]
420
+ const last = focusable[focusable.length - 1]
421
+ if (first === undefined || last === undefined) return
422
+ if (e.shiftKey && document.activeElement === first) {
423
+ e.preventDefault()
424
+ last.focus()
425
+ } else if (!e.shiftKey && document.activeElement === last) {
426
+ e.preventDefault()
427
+ first.focus()
428
+ }
429
+ }
430
+ panel.addEventListener('keydown', onKeyDown)
431
+ return () => panel.removeEventListener('keydown', onKeyDown)
432
+ }, [open, coarse])
433
+
434
+ const handleEnter = (key: string) => onHighlight?.(key)
309
435
  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
436
 
315
437
  const idPrefix = chartId ? `${chartId}-` : ''
316
438
  const vertical = placement === 'left' || placement === 'right'
317
439
  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
440
+ const rolledUp = maxRows === undefined ? 0 : Math.max(entries.length - maxRows, 0)
441
+ const visible = rolledUp > 0 ? entries.slice(0, maxRows) : entries
321
442
 
322
- const nodes: ReactNode[] = []
323
- visible.forEach((item, i) => {
324
- nodes.push(
443
+ // The disclosure panel is a second copy of the same entries: it gets its own id namespace and
444
+ // no `data-legend-key`, so a page query for the band's entries never finds the panel's twins.
445
+ const entryButton = (item: LegendEntry, inPanel = false): ReactNode => {
446
+ // A dots-mode entry names nothing on its own face — its whole point is the color — so a tap
447
+ // opens the same All-N sheet the overflow chip does, rather than silently hiding a series the
448
+ // reader was trying to identify (R2C-8: "what is this?" used to toggle it off instead). Gated
449
+ // on `mode` alone: with `legend.toggle: false` (no `onToggle`) a dots entry still has no OTHER
450
+ // way to name itself, so it still needs the title + disclosure — `onToggle !== undefined` used
451
+ // to suppress both just because toggling happened to be off.
452
+ const isDotTrigger = !inPanel && mode === 'dots'
453
+ return (
325
454
  <button
326
455
  key={item.key}
327
456
  type="button"
328
- data-legend-key={item.key}
457
+ data-basalt-hit
458
+ {...(!inPanel && { 'data-legend-key': item.key })}
329
459
  // The note is the whole point for a series that is invisible in the plot, so it has to
330
460
  // reach a screen reader too — an explicit aria-label would otherwise replace it.
331
461
  aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
332
- {...(onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
462
+ {...(isDotTrigger && { title: item.label, 'aria-haspopup': 'dialog' as const })}
463
+ {...(isDotTrigger
464
+ ? { 'aria-expanded': open, 'aria-controls': panelId }
465
+ : onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
333
466
  style={{
334
467
  ...LEGEND_ITEM_BUTTON,
335
- cursor: onToggle === undefined ? 'default' : 'pointer',
468
+ cursor: onToggle === undefined && !isDotTrigger ? 'default' : 'pointer',
336
469
  // Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
337
470
  // it is the one being hovered.
338
471
  opacity: hidden.has(item.key)
@@ -342,20 +475,33 @@ export function ChartLegend({
342
475
  : 0.3,
343
476
  textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
344
477
  }}
345
- {...(onToggle !== undefined && { onClick: handleToggle })}
346
- onMouseEnter={handleEnter}
478
+ {...(isDotTrigger
479
+ ? { onClick: (e: MouseEvent<HTMLButtonElement>) => openDisclosure(e.currentTarget) }
480
+ : onToggle !== undefined && { onClick: () => onToggle(item.key) })}
481
+ onMouseEnter={() => handleEnter(item.key)}
347
482
  onMouseLeave={handleLeave}
348
- onFocus={handleEnter}
483
+ onFocus={() => handleEnter(item.key)}
349
484
  onBlur={handleLeave}
350
485
  >
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>,
486
+ {!inPanel && mode === 'dots' ? (
487
+ <LegendDot item={item} />
488
+ ) : (
489
+ <>
490
+ <LegendSwatch item={item} idPrefix={inPanel ? `${idPrefix}disclosure-` : idPrefix} />
491
+ <span>{item.label}</span>
492
+ {item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
493
+ {item.children?.map((child) => (
494
+ <LegendChild key={child.key} item={child} />
495
+ ))}
496
+ </>
497
+ )}
498
+ </button>
358
499
  )
500
+ }
501
+
502
+ const nodes: ReactNode[] = []
503
+ visible.forEach((item, i) => {
504
+ nodes.push(entryButton(item))
359
505
  if (dividerAfter.has(i))
360
506
  nodes.push(<LegendDivider key={`divider-${item.key}`} columnLayout={vertical} />)
361
507
  })
@@ -364,11 +510,14 @@ export function ChartLegend({
364
510
  <button
365
511
  key="legend-more"
366
512
  type="button"
367
- aria-expanded={expanded}
368
- onClick={() => setExpanded((prev) => !prev)}
513
+ data-basalt-hit
514
+ aria-expanded={open}
515
+ aria-haspopup="dialog"
516
+ aria-controls={panelId}
517
+ onClick={(e) => (open ? setDisclosure(null) : openDisclosure(e.currentTarget))}
369
518
  style={{ ...LEGEND_ITEM_BUTTON, cursor: 'pointer', textDecoration: 'underline' }}
370
519
  >
371
- {expanded ? 'Show less' : `+${rolledUp} more`}
520
+ {`All ${entries.length}`}
372
521
  </button>,
373
522
  )
374
523
  }
@@ -376,9 +525,44 @@ export function ChartLegend({
376
525
  return (
377
526
  <div
378
527
  {...(className !== undefined && { className })}
379
- style={{ ...wrapperStyle(placement, tier.legendFontSize), ...style }}
528
+ style={{ ...wrapperStyle(placement, tier.legendFontSize, mode === 'dots'), ...style }}
380
529
  >
381
530
  {nodes}
531
+ {open &&
532
+ createPortal(
533
+ <>
534
+ {coarse && (
535
+ <div
536
+ aria-hidden
537
+ onPointerDown={(e) => {
538
+ e.stopPropagation()
539
+ setDisclosure(null)
540
+ }}
541
+ style={{
542
+ position: 'fixed',
543
+ inset: 0,
544
+ // One below the panel it sits behind — still `VX.zIndexFloating`-derived, never a
545
+ // second unrelated literal (B14).
546
+ zIndex: VX.zIndexFloating - 1,
547
+ backgroundColor: alpha(VX.neutral, 0.35),
548
+ }}
549
+ />
550
+ )}
551
+ <dialog
552
+ ref={panelRef}
553
+ id={panelId}
554
+ open
555
+ tabIndex={-1}
556
+ aria-label="Legend"
557
+ {...(coarse && { 'aria-modal': true })}
558
+ data-basalt-legend-disclosure={coarse ? 'sheet' : 'popover'}
559
+ style={{ ...DISCLOSURE_PANEL, fontSize: tier.legendFontSize, ...disclosure }}
560
+ >
561
+ {entries.map((item) => entryButton(item, true))}
562
+ </dialog>
563
+ </>,
564
+ document.body,
565
+ )}
382
566
  </div>
383
567
  )
384
568
  }
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { VX } from '../../tokens'
5
5
  import { fmtTooltipDate } from '../utils/format'
6
- import { useChartTierMetrics } from './chart-tier'
6
+ import { useChartMetrics } from './chart-tier'
7
7
 
8
8
  // Panel bg + shadow-card, radius 8 (docs/DESIGN-SPEC.md §5's "Tooltip/popover/menu" idiom) — the
9
9
  // same depth-via-shadow treatment as ChartCard, never a `border` property. Surfaces resolve per
@@ -11,7 +11,7 @@ import { useChartTierMetrics } from './chart-tier'
11
11
  const TOOLTIP_STYLES: CSSProperties = {
12
12
  position: 'fixed',
13
13
  pointerEvents: 'none',
14
- zIndex: 9999,
14
+ zIndex: VX.zIndexFloating,
15
15
  backgroundColor: VX.surface.panel,
16
16
  borderRadius: 'var(--vx-radius-floating)',
17
17
  padding: '0',
@@ -191,7 +191,7 @@ export function ChartTooltipFloat({
191
191
  const ref = useRef<HTMLDivElement>(null)
192
192
  const [box, setBox] = useState<{ width: number; height: number } | null>(null)
193
193
  const isClient = useIsClient()
194
- const { tooltipMinWidth } = useChartTierMetrics()
194
+ const { tooltipMinWidth } = useChartMetrics()
195
195
 
196
196
  useLayoutEffect(() => {
197
197
  const el = ref.current
@@ -215,6 +215,11 @@ export function ChartTooltipFloat({
215
215
  const vw = window.innerWidth
216
216
  const vh = window.innerHeight
217
217
 
218
+ // An anchor outside the viewport means the chart that owns it has scrolled off-screen. Clamping
219
+ // would paint a follower's box over unrelated chrome (the bottom nav — `R2C-3`); render nothing
220
+ // instead.
221
+ if (anchor.y < 0 || anchor.y > vh) return null
222
+
218
223
  const flipsLeft = anchor.x + TOOLTIP_GAP + width + VIEWPORT_MARGIN > vw
219
224
  const left = flipsLeft
220
225
  ? 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,5 @@
1
1
  import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
2
+ import styles from './HoverOverlay.module.css'
2
3
 
3
4
  /**
4
5
  * Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
@@ -11,6 +12,8 @@ export function HoverOverlay({
11
12
  width,
12
13
  height,
13
14
  onMove,
15
+ onDown,
16
+ onUp,
14
17
  onLeave,
15
18
  onKeyDown,
16
19
  onBlur,
@@ -22,6 +25,11 @@ export function HoverOverlay({
22
25
  width: number
23
26
  height: number
24
27
  onMove: PointerEventHandler<SVGRectElement>
28
+ /** Coarse-pointer tap: resolves + shows the tooltip immediately, with no preceding `pointermove`
29
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip). The press is provisional; `onUp` commits it. */
30
+ onDown?: PointerEventHandler<SVGRectElement>
31
+ /** Commits the provisional touch press so the pin survives the lift. */
32
+ onUp?: PointerEventHandler<SVGRectElement>
25
33
  onLeave: PointerEventHandler<SVGRectElement>
26
34
  /** Present = the overlay is focusable and scrubs on ←/→. */
27
35
  onKeyDown?: KeyboardEventHandler<SVGRectElement>
@@ -35,7 +43,12 @@ export function HoverOverlay({
35
43
  valueText?: string
36
44
  }) {
37
45
  return (
46
+ // `touch-action: pan-y` lives on the plot's `<svg>` (each kind's own), NOT here: Chrome ignores
47
+ // `touch-action` on an SVG child `<rect>`, so the browser claims a horizontal pan and cancels
48
+ // the pointer (`R2C-2`). The `overlay` class owns the focus ring: no UA outline on tap, a VX
49
+ // accent inset under `:focus-visible` (`R2C-15`).
38
50
  <rect
51
+ className={styles.overlay}
39
52
  width={width}
40
53
  height={height}
41
54
  fill="transparent"
@@ -54,11 +67,9 @@ export function HoverOverlay({
54
67
  onKeyDown,
55
68
  ...(onBlur !== undefined && { onBlur }),
56
69
  })}
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
70
  onPointerMove={onMove}
71
+ {...(onDown !== undefined && { onPointerDown: onDown })}
72
+ {...(onUp !== undefined && { onPointerUp: onUp })}
62
73
  onPointerLeave={onLeave}
63
74
  onPointerCancel={onLeave}
64
75
  />
@@ -0,0 +1,37 @@
1
+ import { createContext } from 'react'
2
+
3
+ /**
4
+ * `ChartCard`'s internal context: `inCard` (a frame inside a card treats its computed height as a
5
+ * MINIMUM and grows into the card body), the legend slot — the empty header-row box a chart will portal its legend into
6
+ * (`docs/CHARTS-SPEC.md` §5, Legend), so a card has one chrome band, not two — and `short`, the same
7
+ * `measuredHeight < CARD_SHORT_HEIGHT` flag `ChartCard` already computes for the header-fold law
8
+ * (`data-basalt-card-short`), threaded through so `ChartFrame` can fold its header-slot legend to
9
+ * 'dots' before it eats the plot floor (`docs/CHARTS-SPEC.md` §5, wave 11). `null`/`false` until the slot
10
+ * mounts, and outside any card. Deliberately not exported from any barrel.
11
+ *
12
+ * `claimLegendSlot`/`releaseLegendSlot` arbitrate the slot when a card hosts more than one
13
+ * `ChartFrame` (`docs/waves/PLAN.md` wave 3, P2-9): the first frame to claim an id keeps it for as
14
+ * long as it holds it, every later claimant is told no and falls back to its own band legend
15
+ * instead of a second frame silently portalling into the same node. Outside any card there is no
16
+ * slot to contend for, so the default always grants the claim.
17
+ *
18
+ * `legendSlotVersion` bumps every time the owner releases the slot (round 3's carried gap): a
19
+ * denied frame's claim attempt runs once, in its own mount effect, so with no signal that the slot
20
+ * freed up it would sit denied forever even after the owning frame unmounts. Frames read it only to
21
+ * put it in a dependency array — the effect re-running is the entire point, not the number itself.
22
+ */
23
+ export const ChartCardContext = createContext<{
24
+ legendSlot: HTMLDivElement | null
25
+ inCard: boolean
26
+ short: boolean
27
+ claimLegendSlot: (id: string) => boolean
28
+ releaseLegendSlot: (id: string) => void
29
+ legendSlotVersion: number
30
+ }>({
31
+ legendSlot: null,
32
+ inCard: false,
33
+ short: false,
34
+ claimLegendSlot: () => true,
35
+ releaseLegendSlot: () => {},
36
+ legendSlotVersion: 0,
37
+ })