basalt-ui 1.16.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +58 -4
  3. package/agent/skills/basalt-design/SKILL.md +2 -2
  4. package/dist/charts/cursor/resolve.d.ts +29 -5
  5. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  6. package/dist/charts/cursor/resolve.js +25 -4
  7. package/dist/charts/hooks/useChartCursor.d.ts +8 -1
  8. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  9. package/dist/charts/hooks/useChartCursor.js +6 -2
  10. package/dist/charts/index.d.ts +1 -0
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +9 -0
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +4 -0
  15. package/dist/charts/kinds/DualPanel.d.ts +24 -0
  16. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  17. package/dist/charts/kinds/DualPanel.js +39 -9
  18. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  19. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  20. package/dist/charts/kinds/MultiLine.js +10 -8
  21. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  22. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  23. package/dist/charts/kinds/StackedArea.js +16 -1
  24. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  25. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  27. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  28. package/dist/charts/kinds/ZonedLine.js +4 -0
  29. package/dist/charts/primitives/CartesianChart.d.ts +40 -2
  30. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  31. package/dist/charts/primitives/CartesianChart.js +16 -6
  32. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  33. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  34. package/dist/charts/primitives/ChartFrame.js +1 -1
  35. package/dist/charts/primitives/ChartTooltip.d.ts +12 -2
  36. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  37. package/dist/charts/primitives/ChartTooltip.js +5 -3
  38. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  39. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  40. package/dist/charts/series.d.ts +9 -1
  41. package/dist/charts/series.d.ts.map +1 -1
  42. package/dist/charts/utils/domain.d.ts +15 -0
  43. package/dist/charts/utils/domain.d.ts.map +1 -1
  44. package/dist/charts/utils/domain.js +6 -1
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/cursor/resolve.test.ts +62 -0
  48. package/src/charts/cursor/resolve.ts +71 -7
  49. package/src/charts/hooks/useChartCursor.ts +12 -1
  50. package/src/charts/index.ts +1 -0
  51. package/src/charts/kinds/Bars.test.tsx +85 -0
  52. package/src/charts/kinds/Bars.tsx +13 -0
  53. package/src/charts/kinds/DualPanel.test.tsx +391 -1
  54. package/src/charts/kinds/DualPanel.tsx +74 -8
  55. package/src/charts/kinds/MultiLine.test.tsx +181 -0
  56. package/src/charts/kinds/MultiLine.tsx +19 -8
  57. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  58. package/src/charts/kinds/StackedArea.tsx +25 -1
  59. package/src/charts/kinds/ZonedLine.test.tsx +110 -0
  60. package/src/charts/kinds/ZonedLine.tsx +13 -0
  61. package/src/charts/primitives/CartesianChart.test.tsx +284 -3
  62. package/src/charts/primitives/CartesianChart.tsx +64 -12
  63. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  64. package/src/charts/primitives/ChartFrame.tsx +9 -2
  65. package/src/charts/primitives/ChartTooltip.test.tsx +44 -0
  66. package/src/charts/primitives/ChartTooltip.tsx +14 -2
  67. package/src/charts/series.ts +8 -2
  68. package/src/charts/utils/domain.test.ts +31 -1
  69. package/src/charts/utils/domain.ts +19 -0
@@ -1,6 +1,7 @@
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
+ import { ChartCursorScope } from '../cursor/scope'
4
5
  import type { ChartSeries } from '../series'
5
6
  import { CartesianChart, resolveAxisDomain } from './CartesianChart'
6
7
 
@@ -44,9 +45,27 @@ describe('resolveAxisDomain', () => {
44
45
  expect(min).toBeLessThanOrEqual(-40)
45
46
  })
46
47
 
47
- test('autoMaxFloor raises a low upper bound', () => {
48
+ test('autoMaxFloor raises a low upper bound, then pads it (mirrors autoMinCeil)', () => {
49
+ // The floor clamps the RAW upper bound first (44 -> 500), padding applies after: 500 * 1.1.
48
50
  const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
49
- expect(max).toBe(500)
51
+ expect(max).toBeCloseTo(550, 5)
52
+ })
53
+
54
+ test('autoMaxFloor at exactly the padded case (dataMax 3.2, pad 1.1, floor 6) yields 6.6', () => {
55
+ const single: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
56
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, single)
57
+ expect(max).toBeCloseTo(6.6, 5)
58
+ })
59
+
60
+ test('autoMaxFloor and autoMinCeil pad symmetrically — both clamp first, pad second', () => {
61
+ // Upper: floor 6 clamps 3.2 -> 6, then pads to 6.6 (multiply, away from zero).
62
+ const positive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
63
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, positive)
64
+ expect(max).toBeCloseTo(6.6, 5)
65
+ // Lower: ceil -6 clamps -3.2 -> -6, then pads to -6.6 (multiply, away from zero) — the mirror.
66
+ const negative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => -3.2 }]
67
+ const [min] = resolveAxisDomain({ autoMinCeil: -6, autoPad: 1.1 }, rows, negative)
68
+ expect(min).toBeCloseTo(-6.6, 5)
50
69
  })
51
70
 
52
71
  test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
@@ -69,6 +88,33 @@ describe('resolveAxisDomain', () => {
69
88
  })
70
89
  })
71
90
 
91
+ describe('resolveAxisDomain — autoMaxFloor composed with an all-negative series', () => {
92
+ // getValue -> -d.a - 20 over rows a=[10, 40, 25] yields [-30, -60, -45]: data max is -30, data
93
+ // min is -60 — deliberately NOT a flat series, so this exercises the real clamp-then-pad path
94
+ // rather than the degenerate flat-collapse branch covered separately below.
95
+ const allNegative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => -d.a - 20 }]
96
+
97
+ test('a floor above the data max clamps to the floor, then pads TOWARD zero', () => {
98
+ const [, max] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
99
+ // clamp: Math.max(-30, -10) = -10; padAutoUpper divides for a negative candidate (toward zero).
100
+ expect(max).toBeCloseTo(-10 / 1.1, 10)
101
+ })
102
+
103
+ test('without a floor, the existing all-negative behavior is unchanged', () => {
104
+ const [, max] = resolveAxisDomain(undefined, rows, allNegative)
105
+ // no floor -> candidate stays the raw data max, -30.
106
+ expect(max).toBeCloseTo(-30 / 1.1, 10)
107
+ })
108
+
109
+ test('autoMaxFloor / autoMinCeil clamp-then-pad symmetrically through resolveAxisDomain', () => {
110
+ // Mirror series: same magnitudes, positive instead of negative (data min 30, not data max -30).
111
+ const allPositive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => d.a + 20 }]
112
+ const [, maxFromFloor] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
113
+ const [minFromCeil] = resolveAxisDomain({ autoMinCeil: 10 }, rows, allPositive)
114
+ expect(maxFromFloor).toBeCloseTo(-minFromCeil, 10)
115
+ })
116
+ })
117
+
72
118
  describe('resolveAxisDomain — overlay bounds', () => {
73
119
  test('a zone past the data stretches the axis instead of clipping', () => {
74
120
  const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
@@ -127,6 +173,69 @@ describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', ()
127
173
  })
128
174
  })
129
175
 
176
+ describe('cursorResolution — threads through to sibling resolution (CartesianChart)', () => {
177
+ // Daily calendar Aug 01–14, plus a sibling folded into 2 weekly buckets keyed by each week's
178
+ // leading day (Aug 01, Aug 08) — the exact shape of the playground's "Weekly digest" pairing.
179
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
180
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
181
+ a: i,
182
+ b: i,
183
+ }))
184
+ const foldedRows: Row[] = [
185
+ { date: '2026-08-01', a: 0, b: 0 },
186
+ { date: '2026-08-08', a: 1, b: 1 },
187
+ ]
188
+
189
+ function renderPair(cursorResolution?: 'leading') {
190
+ render(
191
+ <ChartCursorScope>
192
+ <CartesianChart<Row>
193
+ data={dailyRows}
194
+ chartId="daily"
195
+ getX={(d) => d.date}
196
+ series={[seriesFor('a')]}
197
+ ariaLabel="Daily"
198
+ >
199
+ {() => null}
200
+ </CartesianChart>
201
+ <CartesianChart<Row>
202
+ data={foldedRows}
203
+ chartId="folded"
204
+ getX={(d) => d.date}
205
+ series={[seriesFor('a')]}
206
+ ariaLabel="Folded"
207
+ {...(cursorResolution !== undefined && { cursorResolution })}
208
+ >
209
+ {() => null}
210
+ </CartesianChart>
211
+ </ChartCursorScope>,
212
+ )
213
+ }
214
+
215
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (its midpoint is
216
+ // Aug 04) — via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
217
+ function driveDailyToAug05() {
218
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
219
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
220
+ }
221
+
222
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
223
+ renderPair('leading')
224
+ driveDailyToAug05()
225
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
226
+ // Aug 05 is inside the Aug01-07 bucket — 'leading' must resolve to its leading key, Aug 01.
227
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
228
+ })
229
+
230
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket — proves cursorResolution actually threads through, not just the resolver it wraps", () => {
231
+ renderPair()
232
+ driveDailyToAug05()
233
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
234
+ // Aug 05 is closer to Aug 08 (distance 3) than Aug 01 (distance 4) — 'nearest' picks Aug 08.
235
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
236
+ })
237
+ })
238
+
130
239
  describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
131
240
  test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
132
241
  render(
@@ -158,3 +267,175 @@ describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', (
158
267
  expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
159
268
  })
160
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
+ })
@@ -5,11 +5,12 @@ import { useMemo, useRef, useState } from 'react'
5
5
  import type { ReactNode } from 'react'
6
6
  import { VX } from '../../tokens'
7
7
  import type { ChartMargin } from '../../tokens'
8
+ import type { CursorResolution } from '../cursor/resolve'
8
9
  import { useChartCursor } from '../hooks/useChartCursor'
9
10
  import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
10
11
  import { deriveTooltipRows } from '../series'
11
12
  import type { ChartLegendConfig, ChartSeries } from '../series'
12
- import { padAutoLower } from '../utils/domain'
13
+ import { padAutoLower, padAutoUpper } from '../utils/domain'
13
14
  import { fmtAxisDate } from '../utils/format'
14
15
  import { smartTicks, smartTicksEvery } from '../utils/ticks'
15
16
  import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
@@ -41,7 +42,11 @@ export type AxisConfig<T> = {
41
42
  | [number, number]
42
43
  | 'auto'
43
44
  | ((data: readonly T[], visible: readonly ChartSeries<T>[]) => [number, number])
44
- /** When 'auto': the upper bound is at least this. */
45
+ /**
46
+ * When 'auto': the raw upper bound is at least this, and padding is applied AFTER — mirroring
47
+ * `autoMinCeil`, which clamps first and pads second. A floor that padded before the clamp used
48
+ * to land the axis top exactly on the floor value with zero headroom.
49
+ */
45
50
  autoMaxFloor?: number
46
51
  /** When 'auto': the lower bound is at most this. Default 0 (forced zero baseline). `Infinity`
47
52
  * pads down from the raw data minimum instead. */
@@ -82,12 +87,37 @@ export type CartesianTooltipConfig<T> = {
82
87
  follow?: boolean
83
88
  /** Right-aligned badge in the tooltip header (e.g. a status label). */
84
89
  label?: (d: T) => { text: string; color: string } | null
90
+ /**
91
+ * Overrides the tooltip header's date text. Default: `TooltipHeader`'s own `fmtTooltipDate`
92
+ * behavior, unchanged. `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
93
+ * LOCAL `Date` from it — a UTC ISO key then names the wrong day next to `formatX`, the tooltip
94
+ * badge, and every sibling chart, which all resolve locally. Receives the raw `getX` key
95
+ * alongside the hovered datum, so a caller can format from either.
96
+ */
97
+ formatHeader?: (key: string, d: T) => string
85
98
  /** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
86
99
  * argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
87
100
  * stays in sync with legend toggling instead of naming a series the plot no longer draws. */
88
101
  prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
89
102
  /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
90
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
91
121
  }
92
122
 
93
123
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
@@ -149,6 +179,14 @@ export type CartesianChartProps<T> = {
149
179
  series: ChartSeries<T>,
150
180
  visible: readonly ChartSeries<T>[],
151
181
  ) => number | null
182
+ /**
183
+ * How a sibling chart's broadcast cursor key resolves against THIS chart's points. Default
184
+ * `'nearest'`, correct when `getX` returns an instant. Pass `'leading'` when it returns a
185
+ * bucket's leading edge (a weekly series keyed by its Monday): under `'nearest'` a hover landing
186
+ * in the back half of a bucket resolves to the FOLLOWING bucket, so the shared crosshair sits
187
+ * one column right of the data the reader is pointing at.
188
+ */
189
+ cursorResolution?: CursorResolution
152
190
  ariaLabel?: string
153
191
  isPending?: boolean
154
192
  /** Draw ONLY the marks. Everything around them is the primitive's job. */
@@ -196,11 +234,10 @@ export function resolveAxisDomain<T>(
196
234
 
197
235
  const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
198
236
  const ceil = cfg?.autoMinCeil ?? 0
199
- // Pad AWAY from zero, never by multiplying: `max * pad` on an all-negative series moves the
200
- // upper bound further negative (`-5 * 1.1 = -5.5`), i.e. past the data, clipping the largest
201
- // value out of the plot. Mirrors `padAutoLower`'s reasoning for the lower bound.
202
- const paddedMax = max > 0 ? max * pad : max < 0 ? max / pad : 0
203
- const upper = Math.max(paddedMax, cfg?.autoMaxFloor ?? -Infinity)
237
+ // Symmetric with the lower bound: clamp the RAW value against the floor/ceiling first, then pad
238
+ // once, away from zero. Padding before the clamp (the old law) lands a floored axis top exactly
239
+ // on the floor with zero headroom — a target line pinned there sits glued to the plot edge.
240
+ const upper = padAutoUpper(Math.max(max, cfg?.autoMaxFloor ?? -Infinity), pad)
204
241
  const lower = padAutoLower(Math.min(min, ceil), pad)
205
242
  // A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
206
243
  // which is a scale with no extent: every point maps to the same pixel and the line renders as a
@@ -241,6 +278,7 @@ export function CartesianChart<T>({
241
278
  tooltip,
242
279
  margin: marginOverride,
243
280
  cursorValue,
281
+ cursorResolution,
244
282
  ariaLabel,
245
283
  isPending,
246
284
  children,
@@ -274,6 +312,7 @@ export function CartesianChart<T>({
274
312
  {...(tooltip !== undefined && { tooltip })}
275
313
  {...(marginOverride !== undefined && { marginOverride })}
276
314
  {...(cursorValue !== undefined && { cursorValue })}
315
+ {...(cursorResolution !== undefined && { cursorResolution })}
277
316
  {...(ariaLabel !== undefined && { ariaLabel })}
278
317
  plot={plot}
279
318
  highlighted={highlighted}
@@ -311,6 +350,7 @@ function CartesianPlot<T>({
311
350
  tooltip,
312
351
  marginOverride,
313
352
  cursorValue,
353
+ cursorResolution,
314
354
  ariaLabel,
315
355
  plot,
316
356
  highlighted,
@@ -430,6 +470,7 @@ function CartesianPlot<T>({
430
470
  getKey: getX,
431
471
  xScale,
432
472
  marginLeft: margin.left,
473
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
433
474
  })
434
475
 
435
476
  const svgRef = useRef<SVGSVGElement>(null)
@@ -438,6 +479,7 @@ function CartesianPlot<T>({
438
479
  const point = cursor.point
439
480
  const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
440
481
  const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
482
+ const formatHeader = tooltipCfg?.formatHeader
441
483
 
442
484
  // Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
443
485
  // formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
@@ -454,10 +496,17 @@ function CartesianPlot<T>({
454
496
  const rows =
455
497
  tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : []
456
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
+
457
505
  // Anchored mode resolves the crosshair's plot-local x into viewport space; following mode uses
458
- // the pointer position `useChartCursor` already recorded.
459
- const anchorX =
460
- 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
461
510
  const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
462
511
  const tooltipAnchor =
463
512
  svgRect === undefined || anchorX === null
@@ -570,10 +619,13 @@ function CartesianPlot<T>({
570
619
  </Group>
571
620
  </svg>
572
621
 
573
- {tooltipEnabled && cursor.isSource && point !== null && (
574
- <ChartTooltipFloat anchor={tooltipAnchor}>
622
+ {showTooltip && (
623
+ <ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
575
624
  <TooltipHeader
576
625
  date={getX(point)}
626
+ {...(formatHeader !== undefined && {
627
+ format: (key: string) => formatHeader(key, point),
628
+ })}
577
629
  {...(badge !== null && { label: badge.text, labelColor: badge.color })}
578
630
  />
579
631
  <TooltipBody>
@@ -13,9 +13,11 @@
13
13
  * to the DOM harness would only be worth it if a future assertion here needed a real measured size
14
14
  * (a live `ResizeObserver` reading) rather than this SSR fallback rect.
15
15
  */
16
+ import { render } from '@testing-library/react'
16
17
  import { describe, expect, test } from 'bun:test'
17
18
  import { renderToStaticMarkup } from 'react-dom/server'
18
19
  import { ChartFrame } from './ChartFrame'
20
+ import { HoverOverlay } from './HoverOverlay'
19
21
  import type { SeriesStyle } from '../series'
20
22
 
21
23
  const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
@@ -66,6 +68,46 @@ describe('a non-pending ChartFrame is unaffected — the legend and body both re
66
68
  })
67
69
  })
68
70
 
71
+ describe('ariaLabel: the label is announced WITHOUT swallowing the interactive slider', () => {
72
+ // The regression this guards: `role="img"` marks every descendant presentational per the ARIA
73
+ // spec, so a screen reader would announce the label and then never expose `HoverOverlay`'s
74
+ // `role="slider"` at all — the keyboard-scrubbable affordance becomes unreachable with no error
75
+ // anywhere. `role="group"` announces the same label while keeping descendants in the tree.
76
+ test('the outer container is role="group" with the label, never role="img"', () => {
77
+ const { container } = render(
78
+ <ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
79
+ {() => <svg />}
80
+ </ChartFrame>,
81
+ )
82
+ const outer = container.firstElementChild as HTMLElement
83
+ expect(outer.getAttribute('role')).toBe('group')
84
+ expect(outer.getAttribute('aria-label')).toBe('Revenue over time')
85
+ expect(container.querySelector('[role="img"]')).toBeNull()
86
+ })
87
+
88
+ test('the slider stays a reachable descendant of the labeled container', () => {
89
+ const { container } = render(
90
+ <ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
91
+ {() => (
92
+ <svg>
93
+ <HoverOverlay
94
+ width={100}
95
+ height={100}
96
+ onMove={() => {}}
97
+ onLeave={() => {}}
98
+ onKeyDown={() => {}}
99
+ />
100
+ </svg>
101
+ )}
102
+ </ChartFrame>,
103
+ )
104
+ const outer = container.firstElementChild as HTMLElement
105
+ expect(outer.getAttribute('role')).toBe('group')
106
+ const slider = outer.querySelector('[role="slider"]')
107
+ expect(slider).not.toBeNull()
108
+ })
109
+ })
110
+
69
111
  const twoSeries: SeriesStyle[] = [
70
112
  { key: 'a', label: 'Series A', color: '#000', mark: 'line' },
71
113
  { key: 'b', label: 'Series B', color: '#111', mark: 'bar' },
@@ -53,9 +53,16 @@ export type ChartFrameProps = {
53
53
  */
54
54
  isPending?: boolean
55
55
  /**
56
- * Accessible text alternative for the chart, applied as `aria-label` (+ `role="img"`) on the
56
+ * Accessible text alternative for the chart, applied as `aria-label` (+ `role="group"`) on the
57
57
  * outer container so screen readers announce something other than an unlabeled graphic. Every
58
58
  * kind composing `ChartFrame` should accept and forward this from its own props.
59
+ *
60
+ * MUST stay `role="group"`, never `role="img"`. Per the ARIA spec, every descendant of an
61
+ * `role="img"` element is presentational, which erases the `HoverOverlay`'s `role="slider"` from
62
+ * the accessibility tree entirely — a screen reader announces the label and then the
63
+ * keyboard-scrubbable slider underneath it is simply unreachable, silently, with no error
64
+ * anywhere. `role="group"` announces the same label while keeping descendants exposed. Do not
65
+ * "simplify" this back to `img` — it looks like a no-op refactor and it is not.
59
66
  */
60
67
  ariaLabel?: string
61
68
  /** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
@@ -189,7 +196,7 @@ export function ChartFrame({
189
196
  <div
190
197
  ref={containerRef}
191
198
  style={outerStyle(fill, vertical)}
192
- {...(ariaLabel !== undefined && { role: 'img', 'aria-label': ariaLabel })}
199
+ {...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
193
200
  {...(isPending && { 'aria-busy': 'true' })}
194
201
  >
195
202
  {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `TooltipHeader` — the `format` seam that lets a caller override `fmtTooltipDate`. Added because
3
+ * `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
4
+ * domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
5
+ */
6
+ import { render, screen } from '@testing-library/react'
7
+ import { describe, expect, test } from 'bun:test'
8
+ import { renderToStaticMarkup } from 'react-dom/server'
9
+ import { ChartTooltipFloat, TooltipHeader } from './ChartTooltip'
10
+
11
+ describe('TooltipHeader — format prop', () => {
12
+ test('defaults to fmtTooltipDate when omitted', () => {
13
+ const html = renderToStaticMarkup(<TooltipHeader date="2026-04-21" />)
14
+ expect(html).toContain('Tue Apr 21 2026')
15
+ })
16
+
17
+ test('a custom format overrides fmtTooltipDate entirely', () => {
18
+ const html = renderToStaticMarkup(
19
+ <TooltipHeader date="2026-04-21" format={(d) => `custom:${d}`} />,
20
+ )
21
+ expect(html).toContain('custom:2026-04-21')
22
+ expect(html).not.toContain('Tue Apr 21 2026')
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
+ })
@@ -26,10 +26,16 @@ export function TooltipHeader({
26
26
  date,
27
27
  label,
28
28
  labelColor,
29
+ format = fmtTooltipDate,
29
30
  }: {
30
31
  date: string
31
32
  label?: string
32
33
  labelColor?: string
34
+ /** Overrides `fmtTooltipDate` for the header text. Default: today's `fmtTooltipDate` behavior,
35
+ * unchanged. The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and
36
+ * builds a LOCAL `Date`, so a UTC ISO domain key names the wrong day next to `formatX`/the
37
+ * tooltip badge, which both resolve locally — a caller with a UTC key needs to supply its own. */
38
+ format?: (date: string) => string
33
39
  }) {
34
40
  return (
35
41
  <div
@@ -50,7 +56,7 @@ export function TooltipHeader({
50
56
  color: VX.ink,
51
57
  }}
52
58
  >
53
- {fmtTooltipDate(date)}
59
+ {format(date)}
54
60
  </span>
55
61
  {label !== undefined && (
56
62
  <span
@@ -149,9 +155,15 @@ const VIEWPORT_MARGIN = 8
149
155
  */
150
156
  export function ChartTooltipFloat({
151
157
  anchor,
158
+ ariaLive = true,
152
159
  children,
153
160
  }: {
154
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
155
167
  children: ReactNode
156
168
  }) {
157
169
  const ref = useRef<HTMLDivElement>(null)
@@ -190,7 +202,7 @@ export function ChartTooltipFloat({
190
202
  <div
191
203
  ref={ref}
192
204
  role="tooltip"
193
- aria-live="polite"
205
+ {...(ariaLive && { 'aria-live': 'polite' as const })}
194
206
  style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
195
207
  >
196
208
  {children}