basalt-ui 1.16.0 → 1.17.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 (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agent/rules/basalt-charts.md +46 -4
  3. package/dist/charts/cursor/resolve.d.ts +29 -5
  4. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  5. package/dist/charts/cursor/resolve.js +25 -4
  6. package/dist/charts/hooks/useChartCursor.d.ts +8 -1
  7. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  8. package/dist/charts/hooks/useChartCursor.js +6 -2
  9. package/dist/charts/index.d.ts +1 -0
  10. package/dist/charts/index.d.ts.map +1 -1
  11. package/dist/charts/kinds/Bars.d.ts +9 -0
  12. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  13. package/dist/charts/kinds/Bars.js +4 -0
  14. package/dist/charts/kinds/DualPanel.d.ts +17 -0
  15. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  16. package/dist/charts/kinds/DualPanel.js +29 -6
  17. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  18. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  19. package/dist/charts/kinds/MultiLine.js +10 -8
  20. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  21. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  22. package/dist/charts/kinds/StackedArea.js +16 -1
  23. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  24. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  25. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  26. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/ZonedLine.js +4 -0
  28. package/dist/charts/primitives/CartesianChart.d.ts +23 -2
  29. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  30. package/dist/charts/primitives/CartesianChart.js +11 -4
  31. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  32. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartFrame.js +1 -1
  34. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  35. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  36. package/dist/charts/primitives/ChartTooltip.js +3 -2
  37. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  38. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  39. package/dist/charts/series.d.ts +4 -1
  40. package/dist/charts/series.d.ts.map +1 -1
  41. package/dist/charts/utils/domain.d.ts +15 -0
  42. package/dist/charts/utils/domain.d.ts.map +1 -1
  43. package/dist/charts/utils/domain.js +6 -1
  44. package/llms.txt +2 -2
  45. package/package.json +1 -1
  46. package/src/charts/cursor/resolve.test.ts +62 -0
  47. package/src/charts/cursor/resolve.ts +71 -7
  48. package/src/charts/hooks/useChartCursor.ts +12 -1
  49. package/src/charts/index.ts +1 -0
  50. package/src/charts/kinds/Bars.test.tsx +85 -0
  51. package/src/charts/kinds/Bars.tsx +13 -0
  52. package/src/charts/kinds/DualPanel.test.tsx +310 -0
  53. package/src/charts/kinds/DualPanel.tsx +48 -4
  54. package/src/charts/kinds/MultiLine.test.tsx +181 -0
  55. package/src/charts/kinds/MultiLine.tsx +19 -8
  56. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  57. package/src/charts/kinds/StackedArea.tsx +25 -1
  58. package/src/charts/kinds/ZonedLine.test.tsx +110 -0
  59. package/src/charts/kinds/ZonedLine.tsx +13 -0
  60. package/src/charts/primitives/CartesianChart.test.tsx +111 -2
  61. package/src/charts/primitives/CartesianChart.tsx +35 -7
  62. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  63. package/src/charts/primitives/ChartFrame.tsx +9 -2
  64. package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
  65. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  66. package/src/charts/series.ts +3 -2
  67. package/src/charts/utils/domain.test.ts +31 -1
  68. package/src/charts/utils/domain.ts +19 -0
@@ -1,6 +1,7 @@
1
1
  import { fireEvent, render, screen } 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(
@@ -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,6 +87,14 @@ 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. */
@@ -149,6 +162,14 @@ export type CartesianChartProps<T> = {
149
162
  series: ChartSeries<T>,
150
163
  visible: readonly ChartSeries<T>[],
151
164
  ) => number | null
165
+ /**
166
+ * How a sibling chart's broadcast cursor key resolves against THIS chart's points. Default
167
+ * `'nearest'`, correct when `getX` returns an instant. Pass `'leading'` when it returns a
168
+ * bucket's leading edge (a weekly series keyed by its Monday): under `'nearest'` a hover landing
169
+ * in the back half of a bucket resolves to the FOLLOWING bucket, so the shared crosshair sits
170
+ * one column right of the data the reader is pointing at.
171
+ */
172
+ cursorResolution?: CursorResolution
152
173
  ariaLabel?: string
153
174
  isPending?: boolean
154
175
  /** Draw ONLY the marks. Everything around them is the primitive's job. */
@@ -196,11 +217,10 @@ export function resolveAxisDomain<T>(
196
217
 
197
218
  const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
198
219
  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)
220
+ // Symmetric with the lower bound: clamp the RAW value against the floor/ceiling first, then pad
221
+ // once, away from zero. Padding before the clamp (the old law) lands a floored axis top exactly
222
+ // on the floor with zero headroom — a target line pinned there sits glued to the plot edge.
223
+ const upper = padAutoUpper(Math.max(max, cfg?.autoMaxFloor ?? -Infinity), pad)
204
224
  const lower = padAutoLower(Math.min(min, ceil), pad)
205
225
  // A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
206
226
  // which is a scale with no extent: every point maps to the same pixel and the line renders as a
@@ -241,6 +261,7 @@ export function CartesianChart<T>({
241
261
  tooltip,
242
262
  margin: marginOverride,
243
263
  cursorValue,
264
+ cursorResolution,
244
265
  ariaLabel,
245
266
  isPending,
246
267
  children,
@@ -274,6 +295,7 @@ export function CartesianChart<T>({
274
295
  {...(tooltip !== undefined && { tooltip })}
275
296
  {...(marginOverride !== undefined && { marginOverride })}
276
297
  {...(cursorValue !== undefined && { cursorValue })}
298
+ {...(cursorResolution !== undefined && { cursorResolution })}
277
299
  {...(ariaLabel !== undefined && { ariaLabel })}
278
300
  plot={plot}
279
301
  highlighted={highlighted}
@@ -311,6 +333,7 @@ function CartesianPlot<T>({
311
333
  tooltip,
312
334
  marginOverride,
313
335
  cursorValue,
336
+ cursorResolution,
314
337
  ariaLabel,
315
338
  plot,
316
339
  highlighted,
@@ -430,6 +453,7 @@ function CartesianPlot<T>({
430
453
  getKey: getX,
431
454
  xScale,
432
455
  marginLeft: margin.left,
456
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
433
457
  })
434
458
 
435
459
  const svgRef = useRef<SVGSVGElement>(null)
@@ -438,6 +462,7 @@ function CartesianPlot<T>({
438
462
  const point = cursor.point
439
463
  const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
440
464
  const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
465
+ const formatHeader = tooltipCfg?.formatHeader
441
466
 
442
467
  // Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
443
468
  // formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
@@ -574,6 +599,9 @@ function CartesianPlot<T>({
574
599
  <ChartTooltipFloat anchor={tooltipAnchor}>
575
600
  <TooltipHeader
576
601
  date={getX(point)}
602
+ {...(formatHeader !== undefined && {
603
+ format: (key: string) => formatHeader(key, point),
604
+ })}
577
605
  {...(badge !== null && { label: badge.text, labelColor: badge.color })}
578
606
  />
579
607
  <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,23 @@
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 { describe, expect, test } from 'bun:test'
7
+ import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { TooltipHeader } from './ChartTooltip'
9
+
10
+ describe('TooltipHeader — format prop', () => {
11
+ test('defaults to fmtTooltipDate when omitted', () => {
12
+ const html = renderToStaticMarkup(<TooltipHeader date="2026-04-21" />)
13
+ expect(html).toContain('Tue Apr 21 2026')
14
+ })
15
+
16
+ test('a custom format overrides fmtTooltipDate entirely', () => {
17
+ const html = renderToStaticMarkup(
18
+ <TooltipHeader date="2026-04-21" format={(d) => `custom:${d}`} />,
19
+ )
20
+ expect(html).toContain('custom:2026-04-21')
21
+ expect(html).not.toContain('Tue Apr 21 2026')
22
+ })
23
+ })
@@ -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
@@ -96,8 +96,9 @@ export type ChartSeries<T> = SeriesStyle & {
96
96
  * alongside the value so a row can read fields beyond the plotted number (e.g. `97.5 kg (92.5 ×
97
97
  * 3)`). */
98
98
  formatValue?: (v: number, d: T) => string
99
- /** PR star / status dot. Return null for no marker at that point. */
100
- getMarker?: (d: T) => { color?: string; r?: number } | null
99
+ /** PR star / status dot. Return null for no marker at that point. `ring` defaults true (today's
100
+ * punched-out stroke, unchanged); `false` omits the stroke entirely. `fillOpacity` defaults 1. */
101
+ getMarker?: (d: T) => { color?: string; r?: number; fillOpacity?: number; ring?: boolean } | null
101
102
  }
102
103
 
103
104
  /** A single derived tooltip row, ready for `TooltipRow`. */
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { padAutoLower } from './domain'
2
+ import { padAutoLower, padAutoUpper } from './domain'
3
3
 
4
4
  describe('padAutoLower', () => {
5
5
  test('stays at 0 for the default zero-baseline case (never goes negative)', () => {
@@ -22,3 +22,33 @@ describe('padAutoLower', () => {
22
22
  }
23
23
  })
24
24
  })
25
+
26
+ describe('padAutoUpper', () => {
27
+ test('stays at 0 (never pushed positive)', () => {
28
+ expect(padAutoUpper(0, 1.1)).toBe(0)
29
+ })
30
+
31
+ test('pads UP above a positive candidate', () => {
32
+ expect(padAutoUpper(78, 1.1)).toBeCloseTo(85.8, 5)
33
+ })
34
+
35
+ test('pads further up (toward and past zero) for a negative candidate', () => {
36
+ expect(padAutoUpper(-5, 1.1)).toBeCloseTo(-4.5454, 3)
37
+ })
38
+
39
+ test('upper never lands below the candidate it was derived from', () => {
40
+ for (const candidate of [-10, -0.5, 0, 0.5, 10, 78]) {
41
+ expect(padAutoUpper(candidate, 1.1)).toBeGreaterThanOrEqual(candidate)
42
+ }
43
+ })
44
+
45
+ test('the autoMaxFloor case: dataMax 3.2, pad 1.1, floor 6 -> 6.6', () => {
46
+ expect(padAutoUpper(Math.max(3.2, 6), 1.1)).toBeCloseTo(6.6, 5)
47
+ })
48
+
49
+ test('is the exact mirror of padAutoLower — same magnitude, opposite direction', () => {
50
+ for (const candidate of [-10, -0.5, 0.5, 10, 78]) {
51
+ expect(padAutoUpper(candidate, 1.1)).toBeCloseTo(-padAutoLower(-candidate, 1.1), 5)
52
+ }
53
+ })
54
+ })
@@ -20,3 +20,22 @@ export function padAutoLower(candidate: number, pad: number): number {
20
20
  if (candidate === 0) return 0
21
21
  return candidate > 0 ? candidate / pad : candidate * pad
22
22
  }
23
+
24
+ /**
25
+ * Auto y-domain upper-bound padding — the exact mirror of {@link padAutoLower}, sharing its
26
+ * sign-safety: pad AWAY from zero (`upper >= candidate`), never toward it. `candidate` is the
27
+ * caller's `Math.max(dataMax, floor)` — the `autoMaxFloor` clamp has already been applied, so
28
+ * padding runs AFTER the floor, not before it. Applying the floor post-pad (the pre-existing bug)
29
+ * lands a floored axis top exactly on the floor value with zero headroom — a target line pinned to
30
+ * that floor then sits glued to the plot edge instead of getting the same breathing room every
31
+ * other bound gets.
32
+ *
33
+ * - `candidate === 0` stays exactly 0 — never pushed positive.
34
+ * - `candidate > 0` multiplies by `pad`, moving the ceiling further UP (away from zero).
35
+ * - `candidate < 0` divides by `pad`, moving the ceiling UP toward (and past) zero — the mirror of
36
+ * `padAutoLower`'s `candidate > 0` branch.
37
+ */
38
+ export function padAutoUpper(candidate: number, pad: number): number {
39
+ if (candidate === 0) return 0
40
+ return candidate > 0 ? candidate * pad : candidate / pad
41
+ }