basalt-ui 1.17.0 → 1.18.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.18.0](https://github.com/jkrumm/basalt-ui/compare/v1.17.0...v1.18.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * render tooltips on cursor followers, and gate managed-doc drift ([6b46613](https://github.com/jkrumm/basalt-ui/commit/6b466136e1b1dbd88934d339420179768b3e9f8b)), closes [#52](https://github.com/jkrumm/basalt-ui/issues/52)
7
+
1
8
  # [1.17.0](https://github.com/jkrumm/basalt-ui/compare/v1.16.0...v1.17.0) (2026-08-18)
2
9
 
3
10
 
@@ -145,6 +145,18 @@ ships no map kind.
145
145
  onto one domain value, silently dropping one from the plot (see #6 below for why that's the
146
146
  cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
147
147
  already are that seam, and a second prop over one concern would fork them.
148
+ **The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
149
+ its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
150
+ values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
151
+ that is already a regular grid (five-minute buckets ARE evenly spaced); wrong for an event-shaped
152
+ series (speed-test runs, deploys, sessions). Two silent consequences: on a page sharing one
153
+ cursor, event points draw at equal spacing, so the crosshair marks the right point at a different
154
+ screen x than a regularly-sampled sibling — the correlation stays legible in the numbers and not
155
+ in the geometry; and because the domain value must be unique, two events at the same instant
156
+ collapse onto one position and one stops being drawn. `getX` returning a date string reads like a
157
+ time axis and is not one. Tracked as issue #52; a chart whose x is a measured quantity currently
158
+ needs a `basalt/hand-rolled-plot` exemption.
159
+
148
160
  6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
149
161
  external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
150
162
  the page shares one cursor out of the box. `ChartCursorScope` **isolates** a subtree onto a
@@ -227,7 +227,7 @@ Every chart composes the shipped primitives — never hand-rolled equivalents:
227
227
 
228
228
  - **`ChartCard`** wrapper (title + info-tooltip + extra slot, consistent margin).
229
229
  - **`ChartLegend`** (`line | bar | split | splitLine` shapes) — never hand-rolled legend markup.
230
- - **`ChartTooltip`** + `TooltipHeader` / `TooltipRow` / `TooltipBody` — never import `@visx/tooltip`
230
+ - **`ChartTooltipFloat`** + `TooltipHeader` / `TooltipRow` / `TooltipBody` — never import `@visx/tooltip`
231
231
  directly (oxlint bans it in chart files).
232
232
  - **`AxisBottomDate`** / **`AxisLeftNumeric`** / **`AxisRightNumeric`** — never raw
233
233
  `<AxisLeft>`/`<AxisBottom>` (they miss theme tokens + smart ticks). Now lint-enforced:
@@ -262,7 +262,7 @@ resolve/render with no `@mantine/*` installed — CI-tested, repo-local only, no
262
262
  - Every color comes from a token (`VX.*` / palette) — `basalt-ui check-theme` green, zero raw hex.
263
263
  - One hue per metric; categorical multi-color only where the data is genuinely categorical.
264
264
  - Neutral line/axis/grid; soft `AreaGradient` on plain metric lines, opaque stacked-area bands.
265
- - Tooltip = `ChartCard` / `ChartTooltip` primitives, one swatch per row — no hand-rolled markup.
265
+ - Tooltip = `ChartCard` / `ChartTooltipFloat` primitives, one swatch per row — no hand-rolled markup.
266
266
  - Looks right in BOTH schemes (toggled and checked: glow on dark, contrast on light).
267
267
  - New colors landed as `{ light, dark }` pairs in the palette, not inline.
268
268
  - Nothing reads `localStorage.getItem('theme')`.
@@ -69,6 +69,13 @@ export type DualPanelProps<T> = {
69
69
  * alongside the hovered datum.
70
70
  */
71
71
  formatHeader?: (key: string, d: T) => string;
72
+ /**
73
+ * Render this chart's tooltip when it is a cursor FOLLOWER, not only when it is the SOURCE —
74
+ * same seam and same anchored-positioning behaviour as `CartesianTooltipConfig.onFollow`. Default
75
+ * false (today's behaviour). Only the SOURCE's tooltip is `aria-live`; a follower's is visual
76
+ * only.
77
+ */
78
+ onFollow?: boolean;
72
79
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
73
80
  * Default `{ placement: 'bottom' }`. */
74
81
  legend?: ChartLegendConfig | false;
@@ -1 +1 @@
1
- {"version":3,"file":"DualPanel.d.ts","sourceRoot":"","sources":["../../../src/charts/kinds/DualPanel.tsx"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAIzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,WAAW,CAAA;AAK5E,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI;IAC9B,IAAI,EAAE,CAAC,EAAE,CAAA;IACT,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IACtB;gEAC4D;IAC5D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACjC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;8DAC0D;IAC1D,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,CAAA;IACxB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACtC,2EAA2E;IAC3E,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACxD,wGAAwG;IACxG,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;IAClE,oDAAoD;IACpD,QAAQ,CAAC,EAAE,QAAQ,EAAE,CAAA;IACrB,8DAA8D;IAC9D,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;IACxB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IAChC,+FAA+F;IAC/F,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IACnC;;yFAEqF;IACrF,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IACvC;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACzC;iGAC6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,kEAAkE;IAClE,YAAY,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC/D;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IAC5C;4CACwC;IACxC,MAAM,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAA;IAClC,+FAA+F;IAC/F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAID;;;;;;;;;;;GAWG;AACH,iBAAS,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,+BAmClD;AAifD;;;GAGG;AACH,eAAO,MAAM,SAAS,EAA2B,OAAO,cAAc,CAAA"}
1
+ {"version":3,"file":"DualPanel.d.ts","sourceRoot":"","sources":["../../../src/charts/kinds/DualPanel.tsx"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAIzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,WAAW,CAAA;AAK5E,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI;IAC9B,IAAI,EAAE,CAAC,EAAE,CAAA;IACT,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IACtB;gEAC4D;IAC5D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACjC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;8DAC0D;IAC1D,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,CAAA;IACxB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACtC,2EAA2E;IAC3E,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACxD,wGAAwG;IACxG,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;IAClE,oDAAoD;IACpD,QAAQ,CAAC,EAAE,QAAQ,EAAE,CAAA;IACrB,8DAA8D;IAC9D,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;IACxB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IAChC,+FAA+F;IAC/F,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IACnC;;yFAEqF;IACrF,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IACvC;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACzC;iGAC6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,kEAAkE;IAClE,YAAY,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC/D;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;4CACwC;IACxC,MAAM,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAA;IAClC,+FAA+F;IAC/F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAID;;;;;;;;;;;GAWG;AACH,iBAAS,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,+BAmClD;AAggBD;;;GAGG;AACH,eAAO,MAAM,SAAS,EAA2B,OAAO,cAAc,CAAA"}
@@ -5,7 +5,7 @@ import { Group } from "@visx/group";
5
5
  import { scaleLinear, scalePoint } from "@visx/scale";
6
6
  import { Bar, LinePath } from "@visx/shape";
7
7
  import { Threshold } from "@visx/threshold";
8
- import { memo, useMemo } from "react";
8
+ import { memo, useMemo, useRef } from "react";
9
9
  import { AxisBottomDate, AxisLeftNumeric } from "../primitives/Axes.js";
10
10
  import {
11
11
  ChartTooltipFloat,
@@ -75,6 +75,7 @@ function DualPanelPlot(props) {
75
75
  bottomMaxAbsFloor,
76
76
  tooltipLabel,
77
77
  formatHeader,
78
+ onFollow,
78
79
  plot
79
80
  } = props;
80
81
  const { hidden } = plot;
@@ -259,11 +260,17 @@ function DualPanelPlot(props) {
259
260
  }),
260
261
  [visibleSeries, lineValid, xScale, topYScale, getX]
261
262
  );
263
+ const svgRef = useRef(null);
262
264
  const point = cursor.point;
263
265
  const sx = point !== null ? xScale(getX(point)) ?? 0 : 0;
264
266
  const syncedBar = point !== null ? getBar(point) : null;
267
+ const isFollowerRender = !cursor.isSource && onFollow === true;
268
+ const showTooltip = point !== null && (cursor.isSource || isFollowerRender);
269
+ const anchorX = isFollowerRender && point !== null ? sx : null;
270
+ const svgRect = anchorX === null ? void 0 : svgRef.current?.getBoundingClientRect();
271
+ const tooltipAnchor = svgRect === void 0 || anchorX === null ? cursor.anchor : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top };
265
272
  return /* @__PURE__ */ jsxs(Fragment, { children: [
266
- /* @__PURE__ */ jsxs("svg", { width: plot.width, height: plot.height, children: [
273
+ /* @__PURE__ */ jsxs("svg", { ref: svgRef, width: plot.width, height: plot.height, children: [
267
274
  /* @__PURE__ */ jsxs(Group, { left: margin.left, top: margin.top, children: [
268
275
  /* @__PURE__ */ jsx(GridRows, { scale: topYScale, width: xMax, stroke: VX.grid, numTicks: 4 }),
269
276
  /* @__PURE__ */ jsx(ZoneRects, { zones: topZones, width: xMax, leftScale: topYScale }),
@@ -392,7 +399,7 @@ function DualPanelPlot(props) {
392
399
  ] }),
393
400
  point !== null && /* @__PURE__ */ jsx(Group, { left: margin.left, top: margin.top, children: /* @__PURE__ */ jsx(Crosshair, { x: sx, top: 0, bottom: innerH }) })
394
401
  ] }),
395
- cursor.isSource && point !== null && /* @__PURE__ */ jsxs(ChartTooltipFloat, { anchor: cursor.anchor, children: [
402
+ showTooltip && /* @__PURE__ */ jsxs(ChartTooltipFloat, { anchor: tooltipAnchor, ariaLive: cursor.isSource, children: [
396
403
  /* @__PURE__ */ jsx(
397
404
  TooltipHeader,
398
405
  {
@@ -77,6 +77,23 @@ export type CartesianTooltipConfig<T> = {
77
77
  prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode;
78
78
  /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
79
79
  extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode;
80
+ /**
81
+ * Render this chart's tooltip when it is a cursor FOLLOWER (the crosshair came from a sibling
82
+ * chart sharing the cursor), not only when this chart is the SOURCE. Default false — today's
83
+ * behaviour, nothing moves. A follower has no pointer under it, so its tooltip is always ANCHORED
84
+ * to the crosshair — the same positioning `follow: false` gives the source — regardless of what
85
+ * `follow` is set to on this chart: there is no other valid position for a chart nobody is
86
+ * hovering. `follow` keeps governing the SOURCE's own tooltip exactly as before; the two options
87
+ * only interact for a follower, where `onFollow` always wins on positioning. Only the SOURCE's
88
+ * tooltip is `aria-live` — followers announce nothing, so a shared-cursor page doesn't fire N
89
+ * live-region updates per pointer move.
90
+ *
91
+ * Cost, since it scales with the page rather than the chart: anchoring reads one
92
+ * `getBoundingClientRect` per hovered frame (the same cost `follow: false` documents), so N
93
+ * opted-in followers pay N of them per frame. That is the reason this is opt-in per chart rather
94
+ * than a page-level switch — turn it on for the charts a reader actually correlates.
95
+ */
96
+ onFollow?: boolean;
80
97
  };
81
98
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
82
99
  export type PlotContext<T> = {
@@ -1 +1 @@
1
- {"version":3,"file":"CartesianChart.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/CartesianChart.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CartesianChart.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/CartesianChart.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAErD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAIzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAU/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAE7C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3C,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC,CAAA;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;AAKvD;uFACuF;AACvF,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI;IAC1B;;;;;OAKG;IACH,MAAM,CAAC,EACH,CAAC,MAAM,EAAE,MAAM,CAAC,GAChB,MAAM,GACN,CAAC,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,SAAS,WAAW,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAClF;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;sDACkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gFAAgF;IAChF,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9B,+EAA+E;IAC/E,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,CAAA;AAED;;eAEe;AACf,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,OAAO,EAAE,SAAS,WAAW,CAAC,CAAC,CAAC,EAAE,CAAA;IAClC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAC5B,CAAA;AAED,MAAM,MAAM,sBAAsB,CAAC,CAAC,IAAI;IACtC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,uEAAuE;IACvE,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACxD;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IAC5C;;iGAE6F;IAC7F,WAAW,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,0BAA0B,CAAC,CAAC,CAAC,KAAK,SAAS,CAAA;IACrE,0FAA0F;IAC1F,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,0BAA0B,CAAC,CAAC,CAAC,KAAK,SAAS,CAAA;IACnE;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,4FAA4F;AAC5F,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAC3B,IAAI,EAAE,SAAS,CAAC,EAAE,CAAA;IAClB,yFAAyF;IACzF,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,CAAA;IACzB,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAC3B,MAAM,EAAE,UAAU,CAAA;IAClB,MAAM,EAAE,WAAW,CAAA;IACnB,mDAAmD;IACnD,OAAO,EAAE,WAAW,GAAG,IAAI,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,WAAW,CAAA;IACnB,+DAA+D;IAC/D,WAAW,EAAE,CAAC,GAAG,IAAI,CAAA;IACrB,2DAA2D;IAC3D,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAAI;IACnC,IAAI,EAAE,CAAC,EAAE,CAAA;IACT,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAA;IACf,0EAA0E;IAC1E,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;IACtB,8EAA8E;IAC9E,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,CAAA;IACxB,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;IACjB;0EACsE;IACtE,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;IAClB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,6DAA6D;IAC7D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACjC,mEAAmE;IACnE,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAA;IAClB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,SAAS,EAAE,CAAA;IACpB,QAAQ,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,EAAE,CAAA;IACxF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAA;IAClC,8EAA8E;IAC9E,OAAO,CAAC,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAA;IAC3C,mFAAmF;IACnF,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAA;IAC7B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CACZ,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,SAAS,WAAW,CAAC,CAAC,CAAC,EAAE,KAC/B,MAAM,GAAG,IAAI,CAAA;IAClB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0EAA0E;IAC1E,QAAQ,EAAE,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,SAAS,CAAA;CAC7C,CAAA;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,SAAS,EAC9B,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,MAAM,EAAE,SAAS,WAAW,CAAC,CAAC,CAAC,EAAE;AACjC;;;;;;GAMG;AACH,WAAW,GAAE,SAAS,MAAM,EAAO,GAClC,CAAC,MAAM,EAAE,MAAM,CAAC,CAkClB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,EAChC,IAAI,EACJ,OAAO,EACP,IAAI,EACJ,MAAM,EACN,CAAC,EACD,EAAE,EACF,MAAM,EACN,OAAqB,EACrB,KAAK,EACL,MAAM,EACN,QAAQ,EACR,MAAM,EACN,WAAW,EACX,IAAI,EACJ,MAAM,EACN,OAAO,EACP,MAAM,EAAE,cAAc,EACtB,WAAW,EACX,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,QAAQ,GACT,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,SAAS,CAwCpC"}
@@ -228,7 +228,10 @@ function CartesianPlot({
228
228
  [visible, leftFormat, rightFormat]
229
229
  );
230
230
  const rows = tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : [];
231
- const anchorX = tooltipCfg?.follow === false && point !== null && cursorX !== null ? cursorX : null;
231
+ const isFollowerRender = !cursor.isSource && tooltipCfg?.onFollow === true;
232
+ const showTooltip = tooltipEnabled && point !== null && (cursor.isSource || isFollowerRender);
233
+ const useAnchoredPosition = tooltipCfg?.follow === false || isFollowerRender;
234
+ const anchorX = useAnchoredPosition && point !== null && cursorX !== null ? cursorX : null;
232
235
  const svgRect = anchorX === null ? void 0 : svgRef.current?.getBoundingClientRect();
233
236
  const tooltipAnchor = svgRect === void 0 || anchorX === null ? cursor.anchor : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top };
234
237
  const ctx = {
@@ -330,7 +333,7 @@ function CartesianPlot({
330
333
  }
331
334
  )
332
335
  ] }) }),
333
- tooltipEnabled && cursor.isSource && point !== null && /* @__PURE__ */ jsxs(ChartTooltipFloat, { anchor: tooltipAnchor, children: [
336
+ showTooltip && /* @__PURE__ */ jsxs(ChartTooltipFloat, { anchor: tooltipAnchor, ariaLive: cursor.isSource, children: [
334
337
  /* @__PURE__ */ jsx(
335
338
  TooltipHeader,
336
339
  {
@@ -32,11 +32,16 @@ export declare function TooltipBody({ children }: {
32
32
  * The box is measured after mount via `useLayoutEffect`, so the first paint of a given tooltip is
33
33
  * hidden rather than misplaced — a tooltip that flashes at the wrong corner reads as a bug.
34
34
  */
35
- export declare function ChartTooltipFloat({ anchor, children, }: {
35
+ export declare function ChartTooltipFloat({ anchor, ariaLive, children, }: {
36
36
  anchor: {
37
37
  x: number;
38
38
  y: number;
39
39
  } | null;
40
+ /** Whether this tooltip announces itself to screen readers on every position change. Default
41
+ * true. Pass false for a cursor FOLLOWER's tooltip — N followers on a shared-cursor page all
42
+ * announcing on every pointer move would make the page unusable with a screen reader; only the
43
+ * cursor SOURCE should be `aria-live`. */
44
+ ariaLive?: boolean;
40
45
  children: ReactNode;
41
46
  }): import("react").ReactPortal | null;
42
47
  //# sourceMappingURL=ChartTooltip.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ChartTooltip.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/ChartTooltip.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAA;AAuBrD,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,UAAU,EACV,MAAuB,GACxB,EAAE;IACD,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;sGAGkG;IAClG,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;CAClC,+BAoCA;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,KAAK,EACL,KAAK,EACL,UAAU,EACV,KAAK,EACL,WAAW,EACX,MAAM,GACP,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAA;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,uEAAuE;IACvE,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,+BA0CA;AAED,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAEhE;AAOD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,QAAQ,GACT,EAAE;IACD,MAAM,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACvC,QAAQ,EAAE,SAAS,CAAA;CACpB,sCA4CA"}
1
+ {"version":3,"file":"ChartTooltip.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/ChartTooltip.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAA;AAuBrD,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,UAAU,EACV,MAAuB,GACxB,EAAE;IACD,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;sGAGkG;IAClG,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;CAClC,+BAoCA;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,KAAK,EACL,KAAK,EACL,UAAU,EACV,KAAK,EACL,WAAW,EACX,MAAM,GACP,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAA;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,uEAAuE;IACvE,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,+BA0CA;AAED,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAEhE;AAOD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,QAAe,EACf,QAAQ,GACT,EAAE;IACD,MAAM,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACvC;;;8CAG0C;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,EAAE,SAAS,CAAA;CACpB,sCA4CA"}
@@ -121,6 +121,7 @@ const TOOLTIP_GAP = 12;
121
121
  const VIEWPORT_MARGIN = 8;
122
122
  function ChartTooltipFloat({
123
123
  anchor,
124
+ ariaLive = true,
124
125
  children
125
126
  }) {
126
127
  const ref = useRef(null);
@@ -151,7 +152,7 @@ function ChartTooltipFloat({
151
152
  {
152
153
  ref,
153
154
  role: "tooltip",
154
- "aria-live": "polite",
155
+ ...ariaLive && { "aria-live": "polite" },
155
156
  style: { ...TOOLTIP_STYLES, left, top, visibility: box === null ? "hidden" : "visible" },
156
157
  children
157
158
  }
@@ -58,6 +58,11 @@ export type SeriesStyle = {
58
58
  * that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
59
59
  * the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
60
60
  * sub-1 opacity reads as a rendering bug, not as data.
61
+ *
62
+ * Applies to STROKES only, so it silently no-ops on a `mark: 'bar'`/`'area'` series — a bar
63
+ * swatch has no stroke to dim. Dim those with `fillOpacity` instead. This bites the
64
+ * zone/reference-legend idiom in particular (`mark: 'bar'`, `getValue: () => null`), where
65
+ * reaching for `strokeOpacity` looks right and does nothing.
61
66
  */
62
67
  strokeOpacity?: number;
63
68
  /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
@@ -1 +1 @@
1
- {"version":3,"file":"series.d.ts","sourceRoot":"","sources":["../../src/charts/series.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"series.d.ts","sourceRoot":"","sources":["../../src/charts/series.ts"],"names":[],"mappings":"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"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.17.0
1
+ # basalt-ui v1.18.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.17.0
22
+ Version: 1.18.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -6,7 +6,7 @@
6
6
  * `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
7
7
  * keyboard-focusable, so `getByRole('slider')` is unambiguous.
8
8
  */
9
- import { fireEvent, render, screen } from '@testing-library/react'
9
+ import { fireEvent, render, screen, within } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
11
  import { renderToStaticMarkup } from 'react-dom/server'
12
12
  import { ChartCursorScope } from '../cursor/scope'
@@ -439,3 +439,83 @@ describe('DualPanel — cursorResolution threads through to sibling resolution',
439
439
  expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
440
440
  })
441
441
  })
442
+
443
+ describe('DualPanel — onFollow (follower behaviour parity with CartesianChart)', () => {
444
+ type SourceRow = { date: string; shown: number }
445
+ const sourceRows: SourceRow[] = [{ date: '2026-08-01', shown: 10 }]
446
+ const sourceSeries: ChartSeries<SourceRow>[] = [
447
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
448
+ ]
449
+
450
+ function renderPair(onFollow?: boolean, followerRows: Row[] = rows) {
451
+ render(
452
+ <ChartCursorScope>
453
+ <CartesianChart<SourceRow>
454
+ data={sourceRows}
455
+ chartId="dp-of-source"
456
+ getX={(d) => d.date}
457
+ series={sourceSeries}
458
+ ariaLabel="Source"
459
+ legend={false}
460
+ >
461
+ {() => null}
462
+ </CartesianChart>
463
+ <DualPanel<Row>
464
+ data={followerRows}
465
+ chartId="dp-of-follower"
466
+ getX={(d) => d.date}
467
+ series={series}
468
+ getBar={(d) => d.bar}
469
+ barLabel="Bar"
470
+ barColorPositive="#0a0"
471
+ barColorNegative="#a00"
472
+ formatTop={(v) => String(v)}
473
+ formatBottom={(v) => v.toFixed(2)}
474
+ ariaLabel="Follower"
475
+ legend={false}
476
+ {...(onFollow !== undefined && { onFollow })}
477
+ />
478
+ </ChartCursorScope>,
479
+ )
480
+ }
481
+
482
+ test('onFollow: true renders the DualPanel tooltip while a sibling owns the cursor', async () => {
483
+ renderPair(true)
484
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
485
+
486
+ expect(await screen.findByText('V')).toBeTruthy()
487
+ })
488
+
489
+ test('without onFollow (default false), DualPanel renders no tooltip as a follower', async () => {
490
+ renderPair()
491
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
492
+
493
+ // The SOURCE still renders its own tooltip — only the DualPanel follower stays silent.
494
+ expect(await screen.findByText('Shown')).toBeTruthy()
495
+ expect(screen.queryByText('V')).toBeNull()
496
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
497
+ })
498
+
499
+ test('the follower tooltip is not aria-live; the source is', async () => {
500
+ renderPair(true)
501
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
502
+ await screen.findByText('V')
503
+
504
+ const tooltips = screen.getAllByRole('tooltip')
505
+ expect(tooltips).toHaveLength(2)
506
+ const sourceTooltip = tooltips.find((t) => within(t).queryByText('Shown') !== null)
507
+ const followerTooltip = tooltips.find((t) => within(t).queryByText('V') !== null)
508
+ expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
509
+ expect(followerTooltip?.hasAttribute('aria-live')).toBe(false)
510
+ })
511
+
512
+ test('a follower whose domain never resolves the broadcast key renders no tooltip shell', async () => {
513
+ const foreignRows: Row[] = [{ date: '2027-01-15', v: 99, bar: 0.5 }]
514
+ renderPair(true, foreignRows)
515
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
516
+
517
+ expect(await screen.findByText('Shown')).toBeTruthy()
518
+ expect(screen.queryByText('V')).toBeNull()
519
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
520
+ })
521
+ })
@@ -4,7 +4,7 @@ import { Group } from '@visx/group'
4
4
  import { scaleLinear, scalePoint } from '@visx/scale'
5
5
  import { Bar, LinePath } from '@visx/shape'
6
6
  import { Threshold } from '@visx/threshold'
7
- import { memo, useMemo } from 'react'
7
+ import { memo, useMemo, useRef } from 'react'
8
8
  import type { ReactNode } from 'react'
9
9
  import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
10
10
  import {
@@ -84,6 +84,13 @@ export type DualPanelProps<T> = {
84
84
  * alongside the hovered datum.
85
85
  */
86
86
  formatHeader?: (key: string, d: T) => string
87
+ /**
88
+ * Render this chart's tooltip when it is a cursor FOLLOWER, not only when it is the SOURCE —
89
+ * same seam and same anchored-positioning behaviour as `CartesianTooltipConfig.onFollow`. Default
90
+ * false (today's behaviour). Only the SOURCE's tooltip is `aria-live`; a follower's is visual
91
+ * only.
92
+ */
93
+ onFollow?: boolean
87
94
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
88
95
  * Default `{ placement: 'bottom' }`. */
89
96
  legend?: ChartLegendConfig | false
@@ -175,6 +182,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
175
182
  bottomMaxAbsFloor,
176
183
  tooltipLabel,
177
184
  formatHeader,
185
+ onFollow,
178
186
  plot,
179
187
  } = props
180
188
 
@@ -424,13 +432,27 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
424
432
  [visibleSeries, lineValid, xScale, topYScale, getX],
425
433
  )
426
434
 
435
+ const svgRef = useRef<SVGSVGElement>(null)
427
436
  const point = cursor.point
428
437
  const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
429
438
  const syncedBar = point !== null ? getBar(point) : null
430
439
 
440
+ // Same seam as `CartesianChart`: this chart renders its tooltip as the cursor SOURCE (always) or,
441
+ // opted in via `onFollow`, as a FOLLOWER. A follower has no pointer under it, so it is always
442
+ // positioned via the anchored (crosshair) path — `cursor.anchor` stays null on a chart nobody is
443
+ // hovering, so following it here would render nothing.
444
+ const isFollowerRender = !cursor.isSource && onFollow === true
445
+ const showTooltip = point !== null && (cursor.isSource || isFollowerRender)
446
+ const anchorX = isFollowerRender && point !== null ? sx : null
447
+ const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
448
+ const tooltipAnchor =
449
+ svgRect === undefined || anchorX === null
450
+ ? cursor.anchor
451
+ : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top }
452
+
431
453
  return (
432
454
  <>
433
- <svg width={plot.width} height={plot.height}>
455
+ <svg ref={svgRef} width={plot.width} height={plot.height}>
434
456
  {/* Top pane: line series + fill-between + zones + ref lines. */}
435
457
  <Group left={margin.left} top={margin.top}>
436
458
  <GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
@@ -593,8 +615,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
593
615
  )}
594
616
  </svg>
595
617
 
596
- {cursor.isSource && point !== null && (
597
- <ChartTooltipFloat anchor={cursor.anchor}>
618
+ {showTooltip && (
619
+ <ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
598
620
  <TooltipHeader
599
621
  date={getX(point)}
600
622
  {...(formatHeader !== undefined && {
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react'
1
+ import { fireEvent, render, screen, within } from '@testing-library/react'
2
2
  import { describe, expect, test } from 'bun:test'
3
3
  import { renderToStaticMarkup } from 'react-dom/server'
4
4
  import { ChartCursorScope } from '../cursor/scope'
@@ -267,3 +267,175 @@ describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', (
267
267
  expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
268
268
  })
269
269
  })
270
+
271
+ describe('tooltip.onFollow — the follower renders when a SIBLING owns the cursor', () => {
272
+ function renderPair(onFollow?: boolean) {
273
+ render(
274
+ <ChartCursorScope>
275
+ <CartesianChart<Row>
276
+ data={rows}
277
+ chartId="of-source"
278
+ getX={(d) => d.date}
279
+ series={[seriesFor('a')]}
280
+ ariaLabel="Source"
281
+ legend={false}
282
+ >
283
+ {() => null}
284
+ </CartesianChart>
285
+ <CartesianChart<Row>
286
+ data={rows}
287
+ chartId="of-follower"
288
+ getX={(d) => d.date}
289
+ series={[seriesFor('b')]}
290
+ ariaLabel="Follower"
291
+ legend={false}
292
+ tooltip={{ follow: false, ...(onFollow !== undefined && { onFollow }) }}
293
+ >
294
+ {() => null}
295
+ </CartesianChart>
296
+ </ChartCursorScope>,
297
+ )
298
+ }
299
+
300
+ test('onFollow: true renders the follower tooltip rows while the source owns the cursor', async () => {
301
+ renderPair(true)
302
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
303
+
304
+ expect(await screen.findByText('b')).toBeTruthy()
305
+ })
306
+
307
+ test('without onFollow (default false), the follower renders no tooltip at all', async () => {
308
+ renderPair()
309
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
310
+
311
+ // The SOURCE still renders its own tooltip ('a') — only the follower ('b') stays silent.
312
+ expect(await screen.findByText('a')).toBeTruthy()
313
+ expect(screen.queryByText('b')).toBeNull()
314
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
315
+ })
316
+
317
+ // `follow` is deliberately left at its default (true, pointer-tracking) here — unlike every
318
+ // other case in this file, which hardcodes `follow: false` on the follower. That hardcoding
319
+ // means `useAnchoredPosition`'s FIRST clause (`tooltipCfg?.follow === false`) always already
320
+ // satisfies the anchored branch, so those tests can't tell `|| isFollowerRender` apart from a
321
+ // no-op. With `follow` left at its pointer-tracking default, anchoring can only come from
322
+ // `isFollowerRender` — and a follower's own `cursor.anchor` (set only by pointer/keyboard
323
+ // events ON THIS CHART, never by a sibling's) stays null for the whole test, since only the
324
+ // SOURCE is ever interacted with. So: `isFollowerRender` present → anchored position resolves
325
+ // from the crosshair and the tooltip mounts; `isFollowerRender` absent → the pointer-tracking
326
+ // branch falls through to the follower's own (never-set) `cursor.anchor`, which is null, and
327
+ // `ChartTooltipFloat` renders nothing at all for a null anchor. Presence/absence of the
328
+ // follower tooltip is what's observable here — happy-dom zeroes every `getBoundingClientRect`,
329
+ // so the anchor's actual pixel coordinates can't discriminate the two branches.
330
+ test('onFollow: true with follow left at its pointer-tracking default still anchors to the crosshair', async () => {
331
+ render(
332
+ <ChartCursorScope>
333
+ <CartesianChart<Row>
334
+ data={rows}
335
+ chartId="of-anchor-source"
336
+ getX={(d) => d.date}
337
+ series={[seriesFor('a')]}
338
+ ariaLabel="Source"
339
+ legend={false}
340
+ >
341
+ {() => null}
342
+ </CartesianChart>
343
+ <CartesianChart<Row>
344
+ data={rows}
345
+ chartId="of-anchor-follower"
346
+ getX={(d) => d.date}
347
+ series={[seriesFor('b')]}
348
+ ariaLabel="Follower"
349
+ legend={false}
350
+ tooltip={{ onFollow: true }}
351
+ >
352
+ {() => null}
353
+ </CartesianChart>
354
+ </ChartCursorScope>,
355
+ )
356
+
357
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
358
+
359
+ expect(await screen.findByText('b')).toBeTruthy()
360
+ })
361
+ })
362
+
363
+ describe('tooltip.onFollow — aria-live stays SOURCE-only', () => {
364
+ test('the source tooltip is aria-live; the follower tooltip is not', async () => {
365
+ render(
366
+ <ChartCursorScope>
367
+ <CartesianChart<Row>
368
+ data={rows}
369
+ chartId="al-source"
370
+ getX={(d) => d.date}
371
+ series={[seriesFor('a')]}
372
+ ariaLabel="Source"
373
+ legend={false}
374
+ >
375
+ {() => null}
376
+ </CartesianChart>
377
+ <CartesianChart<Row>
378
+ data={rows}
379
+ chartId="al-follower"
380
+ getX={(d) => d.date}
381
+ series={[seriesFor('b')]}
382
+ ariaLabel="Follower"
383
+ legend={false}
384
+ tooltip={{ follow: false, onFollow: true }}
385
+ >
386
+ {() => null}
387
+ </CartesianChart>
388
+ </ChartCursorScope>,
389
+ )
390
+
391
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
392
+ await screen.findByText('b')
393
+
394
+ const tooltips = screen.getAllByRole('tooltip')
395
+ expect(tooltips).toHaveLength(2)
396
+ const sourceTooltip = tooltips.find((t) => within(t).queryByText('a') !== null)
397
+ const followerTooltip = tooltips.find((t) => within(t).queryByText('b') !== null)
398
+ expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
399
+ expect(followerTooltip?.hasAttribute('aria-live')).toBe(false)
400
+ })
401
+ })
402
+
403
+ describe('tooltip.onFollow — a follower whose domain never resolves the broadcast key renders nothing', () => {
404
+ test('an unrelated calendar stays silent, not an empty tooltip shell', async () => {
405
+ const foreignRows: Row[] = [{ date: '2027-01-15', a: 99, b: 99 }]
406
+
407
+ render(
408
+ <ChartCursorScope>
409
+ <CartesianChart<Row>
410
+ data={rows}
411
+ chartId="unres-source"
412
+ getX={(d) => d.date}
413
+ series={[seriesFor('a')]}
414
+ ariaLabel="Source"
415
+ legend={false}
416
+ >
417
+ {() => null}
418
+ </CartesianChart>
419
+ <CartesianChart<Row>
420
+ data={foreignRows}
421
+ chartId="unres-follower"
422
+ getX={(d) => d.date}
423
+ series={[seriesFor('b')]}
424
+ ariaLabel="Follower"
425
+ legend={false}
426
+ tooltip={{ follow: false, onFollow: true }}
427
+ >
428
+ {() => null}
429
+ </CartesianChart>
430
+ </ChartCursorScope>,
431
+ )
432
+
433
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
434
+
435
+ // The SOURCE still renders its own tooltip — only the follower with no resolvable point stays
436
+ // silent, rather than mounting an empty tooltip shell.
437
+ expect(await screen.findByText('a')).toBeTruthy()
438
+ expect(screen.queryByText('b')).toBeNull()
439
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
440
+ })
441
+ })
@@ -101,6 +101,23 @@ export type CartesianTooltipConfig<T> = {
101
101
  prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
102
102
  /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
103
103
  extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
104
+ /**
105
+ * Render this chart's tooltip when it is a cursor FOLLOWER (the crosshair came from a sibling
106
+ * chart sharing the cursor), not only when this chart is the SOURCE. Default false — today's
107
+ * behaviour, nothing moves. A follower has no pointer under it, so its tooltip is always ANCHORED
108
+ * to the crosshair — the same positioning `follow: false` gives the source — regardless of what
109
+ * `follow` is set to on this chart: there is no other valid position for a chart nobody is
110
+ * hovering. `follow` keeps governing the SOURCE's own tooltip exactly as before; the two options
111
+ * only interact for a follower, where `onFollow` always wins on positioning. Only the SOURCE's
112
+ * tooltip is `aria-live` — followers announce nothing, so a shared-cursor page doesn't fire N
113
+ * live-region updates per pointer move.
114
+ *
115
+ * Cost, since it scales with the page rather than the chart: anchoring reads one
116
+ * `getBoundingClientRect` per hovered frame (the same cost `follow: false` documents), so N
117
+ * opted-in followers pay N of them per frame. That is the reason this is opt-in per chart rather
118
+ * than a page-level switch — turn it on for the charts a reader actually correlates.
119
+ */
120
+ onFollow?: boolean
104
121
  }
105
122
 
106
123
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
@@ -479,10 +496,17 @@ function CartesianPlot<T>({
479
496
  const rows =
480
497
  tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : []
481
498
 
499
+ // This chart renders its tooltip either as the cursor SOURCE (always) or, opted in via
500
+ // `onFollow`, as a FOLLOWER (a sibling owns the cursor). A follower has no pointer under it, so
501
+ // it is always positioned via the anchored (crosshair) path below, regardless of `follow`.
502
+ const isFollowerRender = !cursor.isSource && tooltipCfg?.onFollow === true
503
+ const showTooltip = tooltipEnabled && point !== null && (cursor.isSource || isFollowerRender)
504
+
482
505
  // Anchored mode resolves the crosshair's plot-local x into viewport space; following mode uses
483
- // the pointer position `useChartCursor` already recorded.
484
- const anchorX =
485
- tooltipCfg?.follow === false && point !== null && cursorX !== null ? cursorX : null
506
+ // the pointer position `useChartCursor` already recorded. A follower render always anchors, since
507
+ // `follow`'s pointer-tracking path has no pointer to read on this chart.
508
+ const useAnchoredPosition = tooltipCfg?.follow === false || isFollowerRender
509
+ const anchorX = useAnchoredPosition && point !== null && cursorX !== null ? cursorX : null
486
510
  const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
487
511
  const tooltipAnchor =
488
512
  svgRect === undefined || anchorX === null
@@ -595,8 +619,8 @@ function CartesianPlot<T>({
595
619
  </Group>
596
620
  </svg>
597
621
 
598
- {tooltipEnabled && cursor.isSource && point !== null && (
599
- <ChartTooltipFloat anchor={tooltipAnchor}>
622
+ {showTooltip && (
623
+ <ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
600
624
  <TooltipHeader
601
625
  date={getX(point)}
602
626
  {...(formatHeader !== undefined && {
@@ -3,9 +3,10 @@
3
3
  * `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
4
4
  * domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
5
5
  */
6
+ import { render, screen } from '@testing-library/react'
6
7
  import { describe, expect, test } from 'bun:test'
7
8
  import { renderToStaticMarkup } from 'react-dom/server'
8
- import { TooltipHeader } from './ChartTooltip'
9
+ import { ChartTooltipFloat, TooltipHeader } from './ChartTooltip'
9
10
 
10
11
  describe('TooltipHeader — format prop', () => {
11
12
  test('defaults to fmtTooltipDate when omitted', () => {
@@ -21,3 +22,23 @@ describe('TooltipHeader — format prop', () => {
21
22
  expect(html).not.toContain('Tue Apr 21 2026')
22
23
  })
23
24
  })
25
+
26
+ describe('ChartTooltipFloat — ariaLive', () => {
27
+ test('defaults to an aria-live="polite" region', () => {
28
+ render(
29
+ <ChartTooltipFloat anchor={{ x: 10, y: 10 }}>
30
+ <span>content</span>
31
+ </ChartTooltipFloat>,
32
+ )
33
+ expect(screen.getByRole('tooltip').getAttribute('aria-live')).toBe('polite')
34
+ })
35
+
36
+ test('ariaLive: false drops the live-region attribute entirely — a follower is visual only', () => {
37
+ render(
38
+ <ChartTooltipFloat anchor={{ x: 10, y: 10 }} ariaLive={false}>
39
+ <span>content</span>
40
+ </ChartTooltipFloat>,
41
+ )
42
+ expect(screen.getByRole('tooltip').hasAttribute('aria-live')).toBe(false)
43
+ })
44
+ })
@@ -155,9 +155,15 @@ const VIEWPORT_MARGIN = 8
155
155
  */
156
156
  export function ChartTooltipFloat({
157
157
  anchor,
158
+ ariaLive = true,
158
159
  children,
159
160
  }: {
160
161
  anchor: { x: number; y: number } | null
162
+ /** Whether this tooltip announces itself to screen readers on every position change. Default
163
+ * true. Pass false for a cursor FOLLOWER's tooltip — N followers on a shared-cursor page all
164
+ * announcing on every pointer move would make the page unusable with a screen reader; only the
165
+ * cursor SOURCE should be `aria-live`. */
166
+ ariaLive?: boolean
161
167
  children: ReactNode
162
168
  }) {
163
169
  const ref = useRef<HTMLDivElement>(null)
@@ -196,7 +202,7 @@ export function ChartTooltipFloat({
196
202
  <div
197
203
  ref={ref}
198
204
  role="tooltip"
199
- aria-live="polite"
205
+ {...(ariaLive && { 'aria-live': 'polite' as const })}
200
206
  style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
201
207
  >
202
208
  {children}
@@ -66,6 +66,11 @@ export type SeriesStyle = {
66
66
  * that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
67
67
  * the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
68
68
  * sub-1 opacity reads as a rendering bug, not as data.
69
+ *
70
+ * Applies to STROKES only, so it silently no-ops on a `mark: 'bar'`/`'area'` series — a bar
71
+ * swatch has no stroke to dim. Dim those with `fillOpacity` instead. This bites the
72
+ * zone/reference-legend idiom in particular (`mark: 'bar'`, `getValue: () => null`), where
73
+ * reaching for `strokeOpacity` looks right and does nothing.
69
74
  */
70
75
  strokeOpacity?: number
71
76
  /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a