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,11 @@
1
1
  import { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
- import type { KeyboardEvent, PointerEvent } from 'react'
3
+ import type { KeyboardEvent, PointerEvent, RefObject } from 'react'
4
4
  import type { CursorResolution } from '../cursor/resolve'
5
5
  import { buildDomainIndex, classifyDomain, resolveCursorPoint } from '../cursor/resolve'
6
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
7
+ import type { CursorState } from '../cursor/store'
8
+ import { useDismissOnOutside, useTouchPin } from '../cursor/touch-pin'
7
9
 
8
10
  /** Viewport-space pointer anchor the tooltip positions against. */
9
11
  export type CursorAnchor = { x: number; y: number }
@@ -15,23 +17,45 @@ export type ChartCursor<T> = {
15
17
  isSource: boolean
16
18
  /** Latest pointer position in viewport coords, or null when not hovered. */
17
19
  anchor: CursorAnchor | null
20
+ /**
21
+ * True when the interaction that most recently placed THIS chart's cursor had
22
+ * `event.pointerType !== 'mouse'` (touch or pen). Callers fold it into the anchored-tooltip
23
+ * condition (`tooltip.follow` defaulting to `false` on touch, docs/CHARTS-SPEC.md §4) — a follower's own
24
+ * anchoring is unaffected, since a follower is never the one whose pointer set this flag.
25
+ */
26
+ isTouch: boolean
18
27
  onPointerMove: (event: PointerEvent<SVGRectElement>) => void
19
- onPointerLeave: () => void
28
+ /** Coarse-pointer equivalent of hover: resolves the nearest point and shows the tooltip
29
+ * immediately, with no preceding `pointermove` — a plain tap otherwise fires only
30
+ * `pointerdown`/`pointerup`. The press is PROVISIONAL: it shows the tooltip but does not commit a
31
+ * pin, which only `onPointerUp` does. Also what MOVES an existing touch pin to a new tap elsewhere
32
+ * in the same chart (fine pointers ignore it; `onPointerMove` already covers hover for them). */
33
+ onPointerDown: (event: PointerEvent<SVGRectElement>) => void
34
+ /** Commits the provisional touch press — the same `pointerId` as the matching `onPointerDown` —
35
+ * so the pin survives the lift. A `pointerup` for any other pointer is ignored. */
36
+ onPointerUp: (event: PointerEvent<SVGRectElement>) => void
37
+ /** `pointerleave`/`pointercancel`, sharing one handler. A still-UNCOMMITTED touch press cancelled
38
+ * here is the browser taking the gesture (a scroll) and clears; once committed, touch no-ops so a
39
+ * stray `pointercancel` cannot undo the pin. A fine pointer always clears. */
40
+ onPointerLeave: (event: PointerEvent<SVGRectElement>) => void
20
41
  onKeyDown: (event: KeyboardEvent<SVGRectElement>) => void
21
42
  onBlur: () => void
22
43
  }
23
44
 
24
45
  /**
25
46
  * Wires a chart into the ambient cursor store: snaps the pointer to the nearest own point,
26
- * broadcasts it, and reads back a sibling's broadcast through domain-aware resolution. Replaces
27
- * the removed `useHoverSync` + `useChartTooltip` pair, including their `resolveKey` escape hatch —
28
- * resolution is now automatic.
47
+ * broadcasts it, and reads back a sibling's broadcast through domain-aware resolution — automatic,
48
+ * no `resolveKey` escape hatch.
49
+ *
50
+ * The tooltip anchor is coalesced through `requestAnimationFrame` (one local write per frame on a
51
+ * fast scrub); the BROADCAST is deduped instead, so a within-bucket scrub costs nothing and a real
52
+ * bucket change reaches siblings on the same tick.
29
53
  *
30
- * The tooltip anchor is coalesced through `requestAnimationFrame`, so a fast scrub costs one local
31
- * state write per frame instead of one per event. The BROADCAST is not rAF-gated — it is deduped
32
- * instead (the store ignores a set that doesn't change the value), so a scrub within one x-bucket
33
- * costs nothing while a real bucket change reaches siblings on the same tick rather than a frame
34
- * late.
54
+ * Touch model (`docs/CHARTS-SPEC.md` §4): a fine pointer keeps hovering. A coarse pointer
55
+ * (`onPointerDown`) resolves and shows the tooltip immediately, drag-scrubs via `onPointerMove`,
56
+ * and COMMITS a pin on `onPointerUp` — `onPointerLeave` (also wired to `pointercancel`) no-ops once
57
+ * committed. A press the browser turns into a scroll clears instead. The pin moves on a fresh tap,
58
+ * clears on a `document` tap outside `boundaryRef`, on any scroll, or on Escape.
35
59
  */
36
60
  export function useChartCursor<T>({
37
61
  data,
@@ -40,6 +64,7 @@ export function useChartCursor<T>({
40
64
  xScale,
41
65
  marginLeft,
42
66
  resolution = 'nearest',
67
+ boundaryRef,
43
68
  }: {
44
69
  data: readonly T[]
45
70
  chartId: string
@@ -53,10 +78,38 @@ export function useChartCursor<T>({
53
78
  * series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
54
79
  */
55
80
  resolution?: CursorResolution
81
+ /**
82
+ * Element a touch tap OUTSIDE dismisses the pin against (`docs/CHARTS-SPEC.md` §4). One
83
+ * capture-phase `document` `pointerdown` listener clears the pin when the target falls outside
84
+ * this ref — added on pinning, removed on unpin/unmount. Omit to skip outside-dismiss (a tap
85
+ * elsewhere in the chart and Escape still work); never throws for a missing ref.
86
+ */
87
+ boundaryRef?: RefObject<Element | null>
56
88
  }): ChartCursor<T> {
57
89
  const store = useCursorStore()
58
90
  const cursor = useCursorState()
59
91
  const [anchor, setAnchor] = useState<CursorAnchor | null>(null)
92
+ // Whether the interaction that most recently placed THIS chart's cursor was coarse (touch/pen).
93
+ // Branched per EVENT (`event.pointerType`), never a static media query — a hybrid device can
94
+ // carry both inputs, and a media query can't tell which one just fired.
95
+ const [isTouch, setIsTouch] = useState(false)
96
+ // Whether THIS chart currently holds a COMMITTED touch/pen pin — deliberately separate from
97
+ // `isTouch` above, which a subsequent fine-pointer move resets to `false` (that IS the bug this
98
+ // guards against) and which `clear()` never touches. Set on a successful `onPointerUp` commit,
99
+ // cleared by `clear()` OR by losing source ownership — mirrors `useDiscreteCursor`'s `pinned`
100
+ // state, kept as a ref here since nothing needs to re-render off it directly.
101
+ const pinnedRef = useRef(false)
102
+ // A SIBLING chart stealing the shared cursor source (its own touch tap) never calls THIS chart's
103
+ // `clear()` — nothing here observes that tap directly. Left unguarded, `pinnedRef` stayed `true`
104
+ // forever once set, so a later mouse hover on this chart — after it legitimately re-claims
105
+ // `source` — read a stale pin and froze the crosshair (`resolveNearestAt`'s
106
+ // `pointerType === 'mouse' && pinnedRef.current && store.get().source === chartId` guard blocking
107
+ // a hover that was never actually still pinned). Reset the moment this chart is no longer the
108
+ // cursor's source — the one signal that unambiguously means whatever touch pin this chart held
109
+ // is gone, stolen or not.
110
+ useEffect(() => {
111
+ if (cursor.source !== chartId) pinnedRef.current = false
112
+ }, [cursor.source, chartId])
60
113
 
61
114
  // Accessors read through refs so the pointer callbacks stay referentially stable: the natural
62
115
  // call style is an inline arrow, fresh every render, and re-creating these would re-bind the
@@ -76,9 +129,18 @@ export function useChartCursor<T>({
76
129
 
77
130
  const frameRef = useRef<number | null>(null)
78
131
  const pendingRef = useRef<CursorAnchor | null>(null)
132
+ // `pointermove` floods far faster than one nearest-point search is worth — an untethered scrub
133
+ // can fire dozens of native events between paints. `moveFrameRef`/`pendingMoveRef` coalesce the
134
+ // O(n) search itself (not just the anchor, which `frameRef`/`pendingRef` already batch) to one
135
+ // per animation frame — see `onPointerMove` below.
136
+ const moveFrameRef = useRef<number | null>(null)
137
+ const pendingMoveRef = useRef<{ px: number; anchor: CursorAnchor; pointerType: string } | null>(
138
+ null,
139
+ )
79
140
  useEffect(
80
141
  () => () => {
81
142
  if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
143
+ if (moveFrameRef.current !== null) cancelAnimationFrame(moveFrameRef.current)
82
144
  // Release the shared cursor if this chart still owns it. Unmounting while hovered (a filter
83
145
  // drops the chart, a tab switches) fires no leave/blur, so the store would keep pointing at
84
146
  // a dead chartId — and any sibling whose domain resolves that stale key would paint a ghost
@@ -97,17 +159,29 @@ export function useChartCursor<T>({
97
159
  })
98
160
  }, [])
99
161
 
100
- const onPointerMove = useCallback(
101
- (event: PointerEvent<SVGRectElement>) => {
102
- if (data.length === 0) return
103
- // Explicit reference node: `localPoint(event)` resolves it from `event.target`, which is the
104
- // overlay rect only while the overlay is genuinely the topmost hit element. Passing
105
- // `currentTarget` pins the coordinate space to the overlay regardless of what a kind draws
106
- // above it.
107
- const local = localPoint(event.currentTarget, event.nativeEvent)
108
- if (local === null) return
109
- const px = local.x - marginLeft
162
+ // The provisional-press commit/revert machine (`docs/CHARTS-SPEC.md` §4) — shared with
163
+ // `useDiscreteCursor`. `getCommitted` snapshots the STORE's current value (not this chart's own
164
+ // `point`), because the shared cursor is page-wide: a committed pin can belong to a different
165
+ // chart than the one now taking a second, cancelled press.
166
+ const touchPin = useTouchPin<CursorState>({
167
+ getCommitted: () => store.get(),
168
+ setCommitted: (v) => store.set(v.key, v.source, v.kind),
169
+ })
110
170
 
171
+ // The nearest-point search plus the broadcast — shared by the immediate (tap) and throttled
172
+ // (move) resolve paths below, so they cannot drift apart. `px` is the plot-local x, already run
173
+ // through `localPoint` by the caller: a `requestAnimationFrame` boundary has no business holding
174
+ // a live `PointerEvent`, only the plain numbers it already read out of one.
175
+ const resolveNearestAt = useCallback(
176
+ (px: number, anchor: CursorAnchor, pointerType: string) => {
177
+ if (data.length === 0) return
178
+ // A committed touch/pen pin must not be silently overridden by a fine-pointer hover on a
179
+ // hybrid device (a touchscreen laptop with a mouse/trackpad) — mirrors `useDiscreteCursor`'s
180
+ // `pinned` guard on its own hover path. Gated on `store.get().source` (not the reactive
181
+ // `isSource` derived later in this hook) so a pin a SIBLING chart has since stolen no longer
182
+ // blocks this chart's own mouse hover just because this chart's stale `pinnedRef` is still
183
+ // true.
184
+ if (pointerType === 'mouse' && pinnedRef.current && store.get().source === chartId) return
111
185
  let closest = data[0] as T
112
186
  let minDist = Infinity
113
187
  for (const d of data) {
@@ -118,11 +192,79 @@ export function useChartCursor<T>({
118
192
  closest = d
119
193
  }
120
194
  }
121
-
122
- scheduleAnchor({ x: event.clientX, y: event.clientY })
195
+ setIsTouch(pointerType !== 'mouse')
196
+ scheduleAnchor(anchor)
123
197
  store.set(getKeyRef.current(closest), chartId, kind)
124
198
  },
125
- [data, marginLeft, chartId, store, scheduleAnchor, kind],
199
+ [data, chartId, store, scheduleAnchor, kind],
200
+ )
201
+
202
+ // Explicit reference node: `localPoint(event)` resolves it from `event.target`, which is the
203
+ // overlay rect only while the overlay is genuinely the topmost hit element. Passing
204
+ // `currentTarget` pins the coordinate space to the overlay regardless of what a kind draws above it.
205
+ const localX = useCallback(
206
+ (event: PointerEvent<SVGRectElement>): number | null => {
207
+ const local = localPoint(event.currentTarget, event.nativeEvent)
208
+ return local === null ? null : local.x - marginLeft
209
+ },
210
+ [marginLeft],
211
+ )
212
+
213
+ // A tap resolves immediately — one discrete event, not a flood, so it needs no throttle.
214
+ const resolveImmediate = useCallback(
215
+ (event: PointerEvent<SVGRectElement>) => {
216
+ const px = localX(event)
217
+ if (px === null) return
218
+ resolveNearestAt(px, { x: event.clientX, y: event.clientY }, event.pointerType)
219
+ },
220
+ [localX, resolveNearestAt],
221
+ )
222
+
223
+ const onPointerMove = useCallback(
224
+ (event: PointerEvent<SVGRectElement>) => {
225
+ const px = localX(event)
226
+ if (px === null) return
227
+ pendingMoveRef.current = {
228
+ px,
229
+ anchor: { x: event.clientX, y: event.clientY },
230
+ pointerType: event.pointerType,
231
+ }
232
+ if (moveFrameRef.current !== null) return
233
+ moveFrameRef.current = requestAnimationFrame(() => {
234
+ moveFrameRef.current = null
235
+ const pending = pendingMoveRef.current
236
+ pendingMoveRef.current = null
237
+ if (pending !== null) resolveNearestAt(pending.px, pending.anchor, pending.pointerType)
238
+ })
239
+ },
240
+ [localX, resolveNearestAt],
241
+ )
242
+
243
+ // A tap fires `pointerdown` + `pointerup` with no `pointermove` in between, so hover's resolve
244
+ // path never runs for it — this is what makes a tap show anything at all. It also MOVES an
245
+ // existing touch pin: a fresh `pointerdown` elsewhere in the chart re-resolves and re-broadcasts
246
+ // exactly like the first one, with nothing extra to wire.
247
+ const onPointerDown = useCallback(
248
+ (event: PointerEvent<SVGRectElement>) => {
249
+ // A coarse press is PROVISIONAL: snapshot whatever is committed now (so a cancel can restore
250
+ // it) BEFORE the resolve below overwrites the store. A fine pointer never begins a press —
251
+ // its hover path has no pin to protect.
252
+ if (event.pointerType !== 'mouse') touchPin.begin(event.pointerId)
253
+ resolveImmediate(event)
254
+ },
255
+ [resolveImmediate, touchPin],
256
+ )
257
+ // A completed tap: the pin is now committed, so `onPointerLeave`'s touch no-op keeps it past the
258
+ // lift. Only the tracked pointer commits — a stray `pointerup` is ignored. `pointerType ===
259
+ // 'mouse'` is checked FIRST and unconditionally: some browsers alias a touch's `pointerId` to the
260
+ // same value (1) a mouse pointer uses, so without this an unrelated mouse `pointerup` could commit
261
+ // an abandoned touch press it never tracked (mirrors `useDiscreteCursor`'s identical guard).
262
+ const onPointerUp = useCallback(
263
+ (event: PointerEvent<SVGRectElement>) => {
264
+ if (event.pointerType === 'mouse') return
265
+ if (touchPin.commit(event.pointerId)) pinnedRef.current = true
266
+ },
267
+ [touchPin],
126
268
  )
127
269
 
128
270
  const clear = useCallback(() => {
@@ -130,12 +272,35 @@ export function useChartCursor<T>({
130
272
  cancelAnimationFrame(frameRef.current)
131
273
  frameRef.current = null
132
274
  }
275
+ if (moveFrameRef.current !== null) {
276
+ cancelAnimationFrame(moveFrameRef.current)
277
+ moveFrameRef.current = null
278
+ }
133
279
  pendingRef.current = null
280
+ pendingMoveRef.current = null
281
+ touchPin.reset()
282
+ pinnedRef.current = false
134
283
  setAnchor(null)
135
284
  // Only clear the SHARED cursor if this chart still owns it: moving fast from chart A to B lets
136
285
  // A's leave fire after B's move, and an unconditional clear would wipe B's cursor.
137
286
  if (store.get().source === chartId) store.set(null, null, null)
138
- }, [store, chartId])
287
+ }, [store, chartId, touchPin])
288
+
289
+ // `pointerleave`/`pointercancel` share this one handler (`HoverOverlay` wires both to `onLeave`).
290
+ // A still-uncommitted touch press cancelled here is the browser taking the gesture (a scroll):
291
+ // `touchPin.cancel` restores whatever pin was committed before this press started (or leaves it
292
+ // clear when nothing was), rather than dropping a pin this press never owned. Once COMMITTED by
293
+ // touch it is a no-op — a stray `pointercancel` must not undo the pin the tap was meant to set.
294
+ // Escape's `clear()` in `onKeyDown` bypasses this guard entirely, by design: it is the one
295
+ // dismissal that must work regardless of pointer type.
296
+ const onPointerLeave = useCallback(
297
+ (event: PointerEvent<SVGRectElement>) => {
298
+ if (touchPin.cancel(event.pointerId)) return
299
+ if (isTouch) return
300
+ clear()
301
+ },
302
+ [isTouch, clear, touchPin],
303
+ )
139
304
 
140
305
  const onKeyDown = useCallback(
141
306
  (event: KeyboardEvent<SVGRectElement>) => {
@@ -169,13 +334,33 @@ export function useChartCursor<T>({
169
334
  // always carries its own kind by construction.
170
335
  const point =
171
336
  cursor.key === null || cursor.kind !== kind ? null : resolveCursorPoint(index, cursor.key)
337
+ const isSource = cursor.source === chartId
338
+
339
+ // Tap-outside dismissal (spec §5): while THIS chart is both pinned by touch and still the cursor's
340
+ // source, a capture-phase `document` `pointerdown`/`scroll`/`keydown` (shared with
341
+ // `useDiscreteCursor`, see `touch-pin.ts`) clears it the moment the event target falls outside
342
+ // `boundaryRef`. Gating on `isSource` (not `isTouch` alone) means the listener follows the pin —
343
+ // a sibling chart stealing the source on its own touch tap detaches this one instead of leaving a
344
+ // second stale listener alive.
345
+ const isInsideBoundary = useCallback(
346
+ (target: Node): boolean => boundaryRef?.current?.contains(target) === true,
347
+ [boundaryRef],
348
+ )
349
+ useDismissOnOutside({
350
+ enabled: isTouch && isSource && boundaryRef !== undefined,
351
+ isInside: isInsideBoundary,
352
+ onDismiss: clear,
353
+ })
172
354
 
173
355
  return {
174
356
  point,
175
- isSource: cursor.source === chartId,
357
+ isSource,
176
358
  anchor,
359
+ isTouch,
177
360
  onPointerMove,
178
- onPointerLeave: clear,
361
+ onPointerDown,
362
+ onPointerUp,
363
+ onPointerLeave,
179
364
  onKeyDown,
180
365
  onBlur: clear,
181
366
  }
@@ -84,14 +84,6 @@ export {
84
84
  type ResolvedChartState,
85
85
  resolveChartState,
86
86
  } from './primitives/ChartPending'
87
- export {
88
- useChartTier,
89
- useChartTierMetrics,
90
- chartTierMetrics,
91
- resolveChartTier,
92
- type ChartTier,
93
- type ChartTierMetrics,
94
- } from './primitives/chart-tier'
95
87
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
96
88
  export { HatchPattern, hatchFill, hatchSizeFor } from './primitives/HatchPattern'
97
89
  export { ChartCard, type ChartCardProps, type ChartCardSlot } from './primitives/ChartCard'
@@ -357,7 +357,13 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
357
357
 
358
358
  return (
359
359
  <>
360
- <svg ref={band.svgRef} width={plot.width} height={plot.height}>
360
+ <svg
361
+ ref={band.svgRef}
362
+ width={plot.width}
363
+ height={plot.height}
364
+ // `touch-action` on the plot `<svg>`: Chrome ignores it on the overlay `<rect>` (`R2C-2`).
365
+ style={{ touchAction: 'pan-y' }}
366
+ >
361
367
  <defs>
362
368
  <HatchPattern
363
369
  id={hatchId}
@@ -382,12 +388,16 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
382
388
  scale={scale}
383
389
  tickValues={band.tickValues}
384
390
  tickFormat={(v) => formatX(String(v))}
391
+ anchorTerminals={band.xAnchorTerminals}
392
+ {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
385
393
  />
386
394
 
387
395
  <HoverOverlay
388
396
  width={plotWidth}
389
397
  height={stripHeight}
390
398
  onMove={cursor.onPointerMove}
399
+ onDown={cursor.onPointerDown}
400
+ onUp={cursor.onPointerUp}
391
401
  onLeave={cursor.onPointerLeave}
392
402
  onKeyDown={cursor.onKeyDown}
393
403
  onBlur={cursor.onBlur}