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,7 +1,10 @@
1
1
  import { Group } from '@visx/group'
2
2
  import { Pie } from '@visx/shape'
3
- import { memo, useMemo, useState } from 'react'
4
- import type { PointerEvent, ReactNode } from 'react'
3
+ import { memo, useMemo } from 'react'
4
+ import type { ReactNode } from 'react'
5
+ import { useChartSize } from '../hooks/useChartSize'
6
+ import { useDiscreteCursor } from '../hooks/use-discrete-cursor'
7
+ import { useHysteresis } from '../hooks/use-hysteresis'
5
8
  import { assertRequiredProps } from '../../common/validate'
6
9
  import type { BasaltProps } from '../../common/props'
7
10
  import { ChartTooltipFloat, TooltipBody, TooltipRow } from '../primitives/ChartTooltip'
@@ -9,6 +12,15 @@ import { ChartFrame } from '../primitives/ChartFrame'
9
12
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
10
13
  import type { ChartState } from '../primitives/ChartPending'
11
14
  import { VX } from '../../tokens'
15
+ import { resolveContainerClass } from '../../tokens/size-classes'
16
+ import { resolveFrameHeight } from '../primitives/chart-frame-layout'
17
+ import {
18
+ foldOther,
19
+ resolveDonutLayout,
20
+ resolveDonutRadius,
21
+ resolveOtherKey,
22
+ sharePercent,
23
+ } from './donut-layout'
12
24
  import type { SeriesStyle } from '../series'
13
25
  import type { SeriesKey } from '../../register'
14
26
 
@@ -64,14 +76,22 @@ export type DonutProps<K extends string = SeriesKey> = BasaltProps & {
64
76
  state?: ChartState
65
77
  }
66
78
 
79
+ const identityLabel = (key: string): string => key
80
+
67
81
  /**
68
82
  * Radial slice-share chart with a punched-out center label. Composes `ChartFrame` for a
69
83
  * categorical legend derived from the slices (one `SeriesStyle` per slice, `mark: 'bar'`) so the
70
- * legend can never drift from what's plotted — legend-hidden slices drop out of the ring, the
71
- * center total, and the tooltip's "Share" row together (`docs/CHARTS-SPEC.md` §5). No crosshair —
72
- * meaningless for a radial layout. Hover stays local to the pie (dimming siblings on hover) rather
84
+ * legend can never drift from what's plotted — legend-hidden slices drop out of the ring and the
85
+ * tooltip together, while every share is taken of ALL slices so the legend and the tooltip never
86
+ * disagree (`docs/CHARTS-SPEC.md` §5). No crosshair — meaningless for a radial layout. Hover stays local to the pie (dimming siblings on hover) rather
73
87
  * than joining the shared cursor: a date-keyed cursor has no counterpart on a donut, and cross-kind
74
88
  * category sync (donut ↔ bar, via a generalized key) is a distinct, deliberately deferred feature.
89
+ * Tap-to-pin, keyboard stepping and focus over the slice ring come from `useDiscreteCursor`
90
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip), the discrete counterpart of `HoverOverlay`.
91
+ *
92
+ * Layout law (`docs/CHARTS-SPEC.md` §5, Legend): frame W/H > 1.5 puts the legend beside the ring
93
+ * with value and %, the ring capped at `min(h, 0.55w)`; `micro`/`compact` stack the full legend
94
+ * under the ring, never rolled up. 7 or more slices fold the smallest into a neutral "Other".
75
95
  */
76
96
  function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
77
97
  // F-ERR-1: name the component and the prop. Without this a missing accessor surfaces
@@ -82,7 +102,8 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
82
102
  data,
83
103
  height,
84
104
  colorForKey,
85
- seriesLabel = (k) => k,
105
+ formatValue,
106
+ seriesLabel = identityLabel,
86
107
  ariaLabel,
87
108
  isPending,
88
109
  state,
@@ -90,44 +111,118 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
90
111
  style,
91
112
  } = props
92
113
 
93
- const series: SeriesStyle[] = data.map((d) => ({
94
- key: d.key,
95
- label: seriesLabel(d.key),
96
- color: colorForKey(d.key),
97
- mark: 'bar',
98
- }))
114
+ // The legend side depends on the frame's own measured box, which only exists inside
115
+ // `ChartFrame` — so measure the wrapper (same width) one level up. A stated height is used as
116
+ // is; the measured one moves with the legend's placement, so an unstated height gets hysteresis.
117
+ const { ref, width: frameW, height: measuredH } = useChartSize()
118
+ const containerClass = resolveContainerClass(frameW)
119
+ const frameH = height === undefined ? measuredH : resolveFrameHeight(height, containerClass)
120
+ const side = useHysteresis(
121
+ (wasSide) =>
122
+ resolveDonutLayout({
123
+ frameW,
124
+ frameH,
125
+ containerClass,
126
+ wasSide: height === undefined && wasSide,
127
+ }).side,
128
+ false,
129
+ )
130
+
131
+ const slices = useMemo(() => {
132
+ const { kept, other } = foldOther(data)
133
+ const out: Slice[] = kept.map((d) => ({
134
+ key: d.key,
135
+ label: seriesLabel(d.key),
136
+ color: colorForKey(d.key),
137
+ value: d.value,
138
+ }))
139
+ if (other !== null)
140
+ out.push({
141
+ key: resolveOtherKey(kept.map((d) => d.key)),
142
+ label: 'Other',
143
+ color: VX.neutral,
144
+ value: other,
145
+ })
146
+ return out
147
+ }, [data, seriesLabel, colorForKey])
148
+
149
+ // One denominator for the side legend and the tooltip: every slice, hidden or not.
150
+ const total = useMemo(() => slices.reduce((sum, d) => sum + d.value, 0), [slices])
151
+ const series = useMemo<SeriesStyle[]>(
152
+ () =>
153
+ slices.map((d) => ({
154
+ key: d.key,
155
+ label: side
156
+ ? `${d.label} · ${formatValue(d.value)} · ${sharePercent(d.value, total)}%`
157
+ : d.label,
158
+ color: d.color,
159
+ mark: 'bar',
160
+ })),
161
+ [slices, side, formatValue, total],
162
+ )
99
163
 
100
164
  return (
101
- <ChartFrame
102
- series={series}
103
- {...(height !== undefined && { height })}
104
- {...(ariaLabel !== undefined && { ariaLabel })}
105
- {...(isPending !== undefined && { isPending })}
106
- {...(state !== undefined && { state })}
165
+ <div
166
+ ref={ref}
107
167
  {...(className !== undefined && { className })}
108
- {...(style !== undefined && { style })}
168
+ style={{ display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0, ...style }}
109
169
  >
110
- {(plot) => <DonutPlot {...props} plot={plot} />}
111
- </ChartFrame>
170
+ <ChartFrame
171
+ series={series}
172
+ // Never roll the legend up: the full list is the donut's only key. Side legends read as a
173
+ // column. `headerSlot: false` (P1-4) keeps it under/beside the ring even inside a
174
+ // `ChartCard` with a header slot free — a Donut's legend IS its key, so it must never
175
+ // shrink to unlabelled header dots.
176
+ legend={{ placement: side ? 'right' : 'bottom', maxRows: series.length, headerSlot: false }}
177
+ {...(height !== undefined && { height })}
178
+ {...(ariaLabel !== undefined && { ariaLabel })}
179
+ {...(isPending !== undefined && { isPending })}
180
+ {...(state !== undefined && { state })}
181
+ >
182
+ {(plot) => (
183
+ <DonutPlot
184
+ slices={slices}
185
+ total={total}
186
+ side={side}
187
+ frameW={frameW}
188
+ plot={plot}
189
+ formatValue={formatValue}
190
+ {...(props.centerLabel !== undefined && { centerLabel: props.centerLabel })}
191
+ {...(props.centerSubLabel !== undefined && { centerSubLabel: props.centerSubLabel })}
192
+ {...(props.centerContent !== undefined && { centerContent: props.centerContent })}
193
+ {...(props.innerRatio !== undefined && { innerRatio: props.innerRatio })}
194
+ {...(props.padAngle !== undefined && { padAngle: props.padAngle })}
195
+ />
196
+ )}
197
+ </ChartFrame>
198
+ </div>
112
199
  )
113
200
  }
114
201
 
115
- type DonutPlotProps<K extends string = SeriesKey> = DonutProps<K> & {
202
+ type Slice = { key: string; label: string; color: string; value: number }
203
+
204
+ type DonutPlotProps = Pick<
205
+ DonutProps,
206
+ 'formatValue' | 'centerLabel' | 'centerSubLabel' | 'centerContent' | 'innerRatio' | 'padAngle'
207
+ > & {
116
208
  plot: { width: number; height: number; hidden: ReadonlySet<string> }
209
+ slices: Slice[]
210
+ /** Sum of ALL slices (hidden included) — the one denominator for every share. */
211
+ total: number
212
+ side: boolean
213
+ frameW: number
117
214
  }
118
215
 
119
- /** A hovered slice plus the viewport anchor `ChartTooltipFloat` positions against. */
120
- type DonutTip<K extends string> = { key: K; value: number; anchor: { x: number; y: number } }
121
-
122
216
  /** The measured plot — split from {@link DonutInner} so it only draws once `ChartFrame` has
123
217
  * resolved a non-empty plot rect (radius/center depend on the measured size). */
124
- function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
218
+ function DonutPlot(props: DonutPlotProps) {
125
219
  const {
126
- data,
220
+ slices,
221
+ total,
127
222
  plot,
128
- colorForKey,
223
+ side,
224
+ frameW,
129
225
  formatValue,
130
- seriesLabel = (k) => k,
131
226
  centerLabel,
132
227
  centerSubLabel,
133
228
  centerContent,
@@ -136,28 +231,28 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
136
231
  } = props
137
232
  const { width, height, hidden } = plot
138
233
 
139
- const [tip, setTip] = useState<DonutTip<K> | null>(null)
140
- const hoveredKey = tip?.key ?? null
234
+ const visibleData = useMemo(() => slices.filter((d) => !hidden.has(d.key)), [slices, hidden])
141
235
 
142
- const visibleData = useMemo(() => data.filter((d) => !hidden.has(d.key)), [data, hidden])
236
+ // One ring, one shared keyboard host: `useDiscreteCursor` gives arrow-key stepping through the
237
+ // VISIBLE slices (a hidden legend entry is not a reachable target either), plus tap-to-pin on
238
+ // touch. Left/Right and Up/Down are equivalent (no `columns` — a ring has no 2D shape).
239
+ const cursor = useDiscreteCursor<Slice>({
240
+ targets: visibleData,
241
+ getKey: (d) => d.key,
242
+ ariaLabel: 'Donut slices — use arrow keys to select, Escape to dismiss',
243
+ })
244
+ const activeKey = cursor.tip?.target.key ?? null
143
245
 
144
- const radius = Math.min(width, height) / 2 - 4
246
+ const radius = resolveDonutRadius({ plotW: width, plotH: height, frameW, side })
145
247
  const innerRadius = radius * innerRatio
146
248
  const centerX = width / 2
147
249
  const centerY = height / 2
148
250
 
149
- const total = useMemo(() => visibleData.reduce((sum, d) => sum + d.value, 0), [visibleData])
150
-
151
- const show = (d: DonutDatum<K>, event: PointerEvent<SVGGElement>) => {
152
- setTip({ key: d.key, value: d.value, anchor: { x: event.clientX, y: event.clientY } })
153
- }
154
- const hide = () => setTip(null)
155
-
156
251
  return (
157
252
  <div style={{ position: 'relative' }}>
158
- <svg width={width} height={height}>
253
+ <svg width={width} height={height} {...cursor.hostProps}>
159
254
  <Group left={centerX} top={centerY}>
160
- <Pie<DonutDatum<K>>
255
+ <Pie<Slice>
161
256
  data={visibleData}
162
257
  pieValue={(d) => d.value}
163
258
  pieSortValues={() => 0}
@@ -172,26 +267,22 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
172
267
  return (
173
268
  <g
174
269
  key={key}
175
- onPointerEnter={(event) => {
176
- show(arc.data, event)
177
- }}
178
- onPointerMove={(event) => {
179
- show(arc.data, event)
180
- }}
181
- onPointerLeave={() => {
182
- hide()
183
- }}
184
- onPointerCancel={() => {
185
- hide()
186
- }}
270
+ id={cursor.optionId(arc.data)}
271
+ // An SVG `<g>` has no `<option>` counterpart — `useDiscreteCursor`'s
272
+ // listbox/option pair (see its JSDoc) is a role over an arbitrary shape.
273
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
274
+ role="option"
275
+ aria-selected={activeKey === key}
276
+ aria-label={`${arc.data.label}: ${formatValue(arc.data.value)}, ${sharePercent(arc.data.value, total)}%`}
277
+ {...cursor.pointerProps(arc.data)}
187
278
  style={{ cursor: 'pointer' }}
188
279
  >
189
280
  <path
190
281
  d={pie.path(arc) || ''}
191
- fill={colorForKey(key)}
282
+ fill={arc.data.color}
192
283
  stroke={VX.surface.panel}
193
284
  strokeWidth={1.5}
194
- opacity={hoveredKey === null || hoveredKey === key ? 1 : 0.4}
285
+ opacity={activeKey === null || activeKey === key ? 1 : 0.4}
195
286
  />
196
287
  </g>
197
288
  )
@@ -244,19 +335,19 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
244
335
  </div>
245
336
  )}
246
337
 
247
- {tip !== null && (
248
- <ChartTooltipFloat anchor={tip.anchor}>
338
+ {cursor.tip !== null && (
339
+ <ChartTooltipFloat anchor={cursor.tip.anchor}>
249
340
  <TooltipBody>
250
341
  <TooltipRow
251
- color={colorForKey(tip.key)}
252
- label={seriesLabel(tip.key)}
253
- value={formatValue(tip.value)}
342
+ color={cursor.tip.target.color}
343
+ label={cursor.tip.target.label}
344
+ value={formatValue(cursor.tip.target.value)}
254
345
  shape="bar"
255
346
  />
256
347
  <TooltipRow
257
348
  color={VX.grid}
258
349
  label="Share"
259
- value={`${total > 0 ? Math.round((tip.value / total) * 100) : 0}%`}
350
+ value={`${sharePercent(cursor.tip.target.value, total)}%`}
260
351
  shape="bar"
261
352
  />
262
353
  </TooltipBody>
@@ -336,12 +336,16 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
336
336
  [bottomDomain, bottomH, useNiceBottom],
337
337
  )
338
338
 
339
+ // ONE `<svg>` wraps both panes below, so it doubles as the tap-outside boundary
340
+ // (`docs/CHARTS-SPEC.md` §4, Tooltip) for the single cursor they share.
341
+ const svgRef = useRef<SVGSVGElement>(null)
339
342
  const cursor = useChartCursor<T>({
340
343
  data,
341
344
  chartId,
342
345
  getKey: getX,
343
346
  xScale,
344
347
  marginLeft: margin.left,
348
+ boundaryRef: svgRef,
345
349
  ...(cursorResolution !== undefined && { resolution: cursorResolution }),
346
350
  })
347
351
 
@@ -477,7 +481,6 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
477
481
  [visibleSeries, lineValid, xScale, topYScale, getX],
478
482
  )
479
483
 
480
- const svgRef = useRef<SVGSVGElement>(null)
481
484
  const point = cursor.point
482
485
  const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
483
486
  const syncedBar = point !== null ? getBar(point) : null
@@ -485,10 +488,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
485
488
  // Same seam as `CartesianChart`: this chart renders its tooltip as the cursor SOURCE (always) or,
486
489
  // opted in via `onFollow`, as a FOLLOWER. A follower has no pointer under it, so it is always
487
490
  // positioned via the anchored (crosshair) path — `cursor.anchor` stays null on a chart nobody is
488
- // hovering, so following it here would render nothing.
491
+ // hovering, so following it here would render nothing. `DualPanel` has no `follow` prop to
492
+ // override the default, so touch (spec §5) anchors unconditionally, same as an unset `follow`
493
+ // elsewhere.
489
494
  const isFollowerRender = !cursor.isSource && onFollow === true
490
495
  const showTooltip = point !== null && (cursor.isSource || isFollowerRender)
491
- const anchorX = isFollowerRender && point !== null ? sx : null
496
+ const anchorX = (isFollowerRender || cursor.isTouch) && point !== null ? sx : null
492
497
  const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
493
498
  const tooltipAnchor =
494
499
  svgRect === undefined || anchorX === null
@@ -497,7 +502,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
497
502
 
498
503
  return (
499
504
  <>
500
- <svg ref={svgRef} width={plot.width} height={plot.height}>
505
+ <svg
506
+ ref={svgRef}
507
+ width={plot.width}
508
+ height={plot.height}
509
+ // `touch-action` on the plot `<svg>`: Chrome ignores it on the overlay `<rect>` (`R2C-2`).
510
+ style={{ touchAction: 'pan-y' }}
511
+ >
501
512
  {/* Top pane: line series + fill-between + zones + ref lines. */}
502
513
  <Group left={margin.left} top={margin.top}>
503
514
  <GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
@@ -585,6 +596,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
585
596
  width={xMax}
586
597
  height={topH + PANE_GAP}
587
598
  onMove={cursor.onPointerMove}
599
+ onDown={cursor.onPointerDown}
600
+ onUp={cursor.onPointerUp}
588
601
  onLeave={cursor.onPointerLeave}
589
602
  onKeyDown={cursor.onKeyDown}
590
603
  onBlur={cursor.onBlur}
@@ -648,6 +661,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
648
661
  width={xMax}
649
662
  height={bottomH + margin.bottom}
650
663
  onMove={cursor.onPointerMove}
664
+ onDown={cursor.onPointerDown}
665
+ onUp={cursor.onPointerUp}
651
666
  onLeave={cursor.onPointerLeave}
652
667
  />
653
668
  </Group>
@@ -1,6 +1,6 @@
1
1
  import { Group } from '@visx/group'
2
- import type { PointerEvent, ReactNode } from 'react'
3
- import { memo, useMemo, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { memo, useMemo } from 'react'
4
4
  import { assertRequiredProps } from '../../common/validate'
5
5
  import type { BasaltProps } from '../../common/props'
6
6
  import {
@@ -11,7 +11,8 @@ import {
11
11
  } from '../primitives/ChartTooltip'
12
12
  import { ChartFrame } from '../primitives/ChartFrame'
13
13
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
14
- import { useChartTierMetrics } from '../primitives/chart-tier'
14
+ import { useChartMetrics } from '../primitives/chart-tier'
15
+ import { useDiscreteCursor } from '../hooks/use-discrete-cursor'
15
16
  import type { ChartState } from '../primitives/ChartPending'
16
17
  import { maxTextWidth } from '../utils/measure-text'
17
18
  import { thinLabels, xLabelPxFor } from '../utils/ticks'
@@ -20,8 +21,9 @@ import { VX, alpha } from '../../tokens'
20
21
  /** A single resolved heatmap cell — the unit the tooltip and hover operate on. */
21
22
  type HeatmapCell = { row: string; col: string; value: number }
22
23
 
23
- /** A hovered cell plus the viewport anchor `ChartTooltipFloat` positions against. */
24
- type HeatmapTip = HeatmapCell & { anchor: { x: number; y: number } }
24
+ /** One grid position, data or not — `useDiscreteCursor`'s target unit. `value` is `undefined` for
25
+ * an (row, col) pair with no data, which stays a keyboard stop but never shows a tooltip. */
26
+ type CellTarget = { row: string; col: string; value: number | undefined }
25
27
 
26
28
  export type HeatmapProps<T> = BasaltProps & {
27
29
  data: T[]
@@ -115,6 +117,13 @@ const LEGEND_LABEL_H = 16
115
117
  * right gutter reserves half the widest column label, so no two painted labels overlap and the
116
118
  * last one cannot clip.
117
119
  *
120
+ * Tap-to-pin, keyboard stepping and focus over the grid come from `useDiscreteCursor`
121
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip), the discrete counterpart of `HoverOverlay`. Its targets are
122
+ * the FULL row-major grid (data cells and empty ones alike) so Up/Down can jump one row via a fixed
123
+ * `columns` stride; only a cell WITH data renders a tooltip or takes pointer input, matching today's
124
+ * hover behavior — an empty cell is still a keyboard stop (so the grid reads as one rectangle), it
125
+ * just carries no readout.
126
+ *
118
127
  * Composes `ChartFrame` purely for measuring (`height`/`aspectRatio`/`fill`, the same three
119
128
  * sizing modes every other kind exposes) — `legend={false}` and an empty `series` opt out of
120
129
  * `ChartFrame`'s own derived legend, since Heatmap already ships its own gradient strip.
@@ -177,9 +186,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
177
186
  // The row/column labels and the gradient legend's endpoints read like axis ticks, so they take
178
187
  // the tier's tick font for the same reason `Axes.tsx` does — a 360px heatmap that painted its
179
188
  // categories at the desktop size would be the one chart still ignoring §8.
180
- const { axisFont } = useChartTierMetrics()
181
-
182
- const [tip, setTip] = useState<HeatmapTip | null>(null)
189
+ const { axisFont } = useChartMetrics()
183
190
 
184
191
  const rows = useMemo(() => rowsProp ?? firstSeen(data, getRow), [rowsProp, data, getRow])
185
192
  const cols = useMemo(() => colsProp ?? firstSeen(data, getCol), [colsProp, data, getCol])
@@ -196,6 +203,36 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
196
203
  return { lookup: map, max: m }
197
204
  }, [data, getRow, getCol, getValue])
198
205
 
206
+ // The FULL row-major grid (gaps included) so `columns: cols.length` gives `useDiscreteCursor` a
207
+ // real row stride for Up/Down — a targets list built only from cells WITH data would shift that
208
+ // stride the moment a row has a gap.
209
+ const cellTargets = useMemo<CellTarget[]>(() => {
210
+ const out: CellTarget[] = []
211
+ for (const row of rows) {
212
+ for (const col of cols) out.push({ row, col, value: lookup.get(cellKey(row, col)) })
213
+ }
214
+ return out
215
+ }, [rows, cols, lookup])
216
+ const cursor = useDiscreteCursor<CellTarget>({
217
+ targets: cellTargets,
218
+ getKey: (c) => cellKey(c.row, c.col),
219
+ columns: cols.length,
220
+ ariaLabel: 'Heatmap cells — use arrow keys to navigate, Escape to dismiss',
221
+ })
222
+ // Resolved once here (not inline in the JSX below) so `value` narrows to `number` — a keyboard
223
+ // stop on an empty cell has a `tip` but no readout to show.
224
+ const activeTip =
225
+ cursor.tip !== null && cursor.tip.target.value !== undefined
226
+ ? {
227
+ anchor: cursor.tip.anchor,
228
+ cell: {
229
+ row: cursor.tip.target.row,
230
+ col: cursor.tip.target.col,
231
+ value: cursor.tip.target.value,
232
+ },
233
+ }
234
+ : null
235
+
199
236
  const rowLabels = useMemo(() => rows.map(rowLabel), [rows, rowLabel])
200
237
  const colLabels = useMemo(() => cols.map(colLabel), [cols, colLabel])
201
238
 
@@ -223,23 +260,27 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
223
260
  [rowLabels, cellH, axisFont],
224
261
  )
225
262
 
226
- const show = (row: string, col: string, value: number, event: PointerEvent<SVGRectElement>) => {
227
- setTip({ row, col, value, anchor: { x: event.clientX, y: event.clientY } })
228
- }
229
- const hide = () => setTip(null)
230
-
231
263
  return (
232
264
  <div style={{ position: 'relative' }}>
233
- <svg width={width} height={height}>
265
+ <svg width={width} height={height} {...cursor.hostProps}>
234
266
  <Group left={PAD_LEFT} top={PAD_TOP}>
235
267
  {rows.flatMap((row, ri) =>
236
268
  cols.map((col, ci) => {
237
269
  const value = lookup.get(cellKey(row, col))
238
270
  const has = value !== undefined
239
271
  const cellFill = has ? alpha(color, intensity(value, max)) : alpha(VX.neutral, 0.04)
272
+ const target: CellTarget = { row, col, value }
273
+ const isActive = cursor.tip?.target.row === row && cursor.tip?.target.col === col
240
274
  return (
241
275
  <rect
242
276
  key={cellKey(row, col)}
277
+ id={cursor.optionId(target)}
278
+ // An SVG `<rect>` has no `<option>` counterpart — `useDiscreteCursor`'s
279
+ // listbox/option pair (see its JSDoc) is a role over an arbitrary shape.
280
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
281
+ role="option"
282
+ aria-selected={isActive}
283
+ aria-label={`${rowLabels[ri]}: ${colLabels[ci]}: ${value !== undefined ? formatValue(value) : 'no data'}`}
243
284
  x={ci * cellW + cellGap / 2}
244
285
  y={ri * cellH + cellGap / 2}
245
286
  width={Math.max(0, cellW - cellGap)}
@@ -247,9 +288,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
247
288
  rx={cellRadius}
248
289
  fill={cellFill}
249
290
  style={{ cursor: has ? 'pointer' : 'default' }}
250
- onPointerMove={(e) => has && show(row, col, value, e)}
251
- onPointerLeave={hide}
252
- onPointerCancel={hide}
291
+ {...(has ? cursor.pointerProps(target) : {})}
253
292
  />
254
293
  )
255
294
  }),
@@ -328,17 +367,17 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
328
367
  )}
329
368
  </svg>
330
369
 
331
- {tip !== null && (
332
- <ChartTooltipFloat anchor={tip.anchor}>
333
- <TooltipHeader date={rowLabel(tip.row)} label={colLabel(tip.col)} />
370
+ {activeTip !== null && (
371
+ <ChartTooltipFloat anchor={activeTip.anchor}>
372
+ <TooltipHeader date={rowLabel(activeTip.cell.row)} label={colLabel(activeTip.cell.col)} />
334
373
  <TooltipBody>
335
374
  <TooltipRow
336
375
  color={alpha(color, 0.9)}
337
376
  shape="bar"
338
377
  label="Value"
339
- value={formatValue(tip.value)}
378
+ value={formatValue(activeTip.cell.value)}
340
379
  />
341
- {renderTooltip?.(tip)}
380
+ {renderTooltip?.(activeTip.cell)}
342
381
  </TooltipBody>
343
382
  </ChartTooltipFloat>
344
383
  )}
@@ -362,7 +362,13 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
362
362
 
363
363
  return (
364
364
  <>
365
- <svg ref={band.svgRef} width={plot.width} height={plot.height}>
365
+ <svg
366
+ ref={band.svgRef}
367
+ width={plot.width}
368
+ height={plot.height}
369
+ // `touch-action` on the plot `<svg>`: Chrome ignores it on the overlay `<rect>` (`R2C-2`).
370
+ style={{ touchAction: 'pan-y' }}
371
+ >
366
372
  <defs>
367
373
  <HatchPattern
368
374
  id={hatchId}
@@ -383,19 +389,21 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
383
389
 
384
390
  {/* One axis per pane, each in its own scale's units — a single shared axis would be
385
391
  wrong for at least one of them whenever the two domains differ, which is always. */}
386
- {upVisible && (
392
+ {upVisible && band.yPlacement !== 'none' && (
387
393
  <AxisLeftNumeric
388
394
  scale={upAxisScale}
389
395
  numTicks={upTicks}
390
396
  tickFormat={(v) => up.format(Number(v))}
397
+ inside={band.yPlacement === 'inside'}
391
398
  />
392
399
  )}
393
- {downVisible && (
400
+ {downVisible && band.yPlacement !== 'none' && (
394
401
  <Group top={baseline}>
395
402
  <AxisLeftNumeric
396
403
  scale={downScale}
397
404
  numTicks={downTicks}
398
405
  tickFormat={(v) => down.format(Number(v))}
406
+ inside={band.yPlacement === 'inside'}
399
407
  />
400
408
  </Group>
401
409
  )}
@@ -416,12 +424,16 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
416
424
  scale={scale}
417
425
  tickValues={band.tickValues}
418
426
  tickFormat={(v) => formatX(String(v))}
427
+ anchorTerminals={band.xAnchorTerminals}
428
+ {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
419
429
  />
420
430
 
421
431
  <HoverOverlay
422
432
  width={plotWidth}
423
433
  height={barBand}
424
434
  onMove={cursor.onPointerMove}
435
+ onDown={cursor.onPointerDown}
436
+ onUp={cursor.onPointerUp}
425
437
  onLeave={cursor.onPointerLeave}
426
438
  onKeyDown={cursor.onKeyDown}
427
439
  onBlur={cursor.onBlur}