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 { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import type { KeyboardEvent, PointerEvent } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
5
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
6
7
 
@@ -38,6 +39,7 @@ export function useChartCursor<T>({
38
39
  getKey,
39
40
  xScale,
40
41
  marginLeft,
42
+ resolution = 'nearest',
41
43
  }: {
42
44
  data: readonly T[]
43
45
  chartId: string
@@ -45,6 +47,12 @@ export function useChartCursor<T>({
45
47
  /** Own x scale — maps a domain key to a plot-local x offset. */
46
48
  xScale: (key: string) => number | undefined
47
49
  marginLeft: number
50
+ /**
51
+ * How a sibling's broadcast key resolves against this chart's own points. Default `'nearest'`
52
+ * (point domains). Pass `'leading'` when `getKey` returns a bucket's leading edge (a weekly
53
+ * series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
54
+ */
55
+ resolution?: CursorResolution
48
56
  }): ChartCursor<T> {
49
57
  const store = useCursorStore()
50
58
  const cursor = useCursorState()
@@ -58,7 +66,10 @@ export function useChartCursor<T>({
58
66
  const xScaleRef = useRef(xScale)
59
67
  xScaleRef.current = xScale
60
68
 
61
- const index = useMemo(() => buildDomainIndex(data, getKeyRef.current), [data])
69
+ const index = useMemo(
70
+ () => buildDomainIndex(data, getKeyRef.current, resolution),
71
+ [data, resolution],
72
+ )
62
73
 
63
74
  const frameRef = useRef<number | null>(null)
64
75
  const pendingRef = useRef<CursorAnchor | null>(null)
@@ -91,6 +91,7 @@ export { AreaGradient, areaFillUrl } from './primitives/AreaGradient'
91
91
 
92
92
  // ── Hooks ────────────────────────────────────────────────────────────────
93
93
  export { useChartCursor, type ChartCursor, type CursorAnchor } from './hooks/useChartCursor'
94
+ export type { CursorResolution } from './cursor/resolve'
94
95
  export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
95
96
 
96
97
  // ── Utils ────────────────────────────────────────────────────────────────
@@ -8,6 +8,9 @@
8
8
  */
9
9
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
+ import { ChartCursorScope } from '../cursor/scope'
12
+ import { CartesianChart } from '../primitives/CartesianChart'
13
+ import type { ChartSeries } from '../series'
11
14
  import { Bars } from './Bars'
12
15
  import type { BarsBar } from './Bars'
13
16
 
@@ -89,6 +92,88 @@ describe('Bars — lines[].strokeOpacity dims the plotted stroke', () => {
89
92
  })
90
93
  })
91
94
 
95
+ describe('Bars — formatX', () => {
96
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
97
+ const { container } = render(
98
+ <Bars<Row>
99
+ data={rows}
100
+ chartId="bars-formatx"
101
+ getX={(d) => d.date}
102
+ getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
103
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
104
+ formatX={(key) => `X:${key}`}
105
+ />,
106
+ )
107
+ expect(container.innerHTML).toContain('X:2026-08-01')
108
+ })
109
+ })
110
+
111
+ describe('Bars — cursorResolution threads through to sibling resolution', () => {
112
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a Bars sibling folded into
113
+ // 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — the exact shape of the
114
+ // playground's "Weekly digest" pairing. Proves the prop reaches THIS kind, not just
115
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
116
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
117
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
118
+ shown: i,
119
+ quiet: i,
120
+ trend: i,
121
+ }))
122
+ const foldedRows: Row[] = [
123
+ { date: '2026-08-01', shown: 0, quiet: 0, trend: 0 },
124
+ { date: '2026-08-08', shown: 1, quiet: 1, trend: 1 },
125
+ ]
126
+ const dailySeries: ChartSeries<Row>[] = [
127
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
128
+ ]
129
+
130
+ function renderPair(cursorResolution?: 'leading') {
131
+ render(
132
+ <ChartCursorScope>
133
+ <CartesianChart<Row>
134
+ data={dailyRows}
135
+ chartId="bars-daily"
136
+ getX={(d) => d.date}
137
+ series={dailySeries}
138
+ ariaLabel="Daily"
139
+ >
140
+ {() => null}
141
+ </CartesianChart>
142
+ <Bars<Row>
143
+ data={foldedRows}
144
+ chartId="bars-folded"
145
+ getX={(d) => d.date}
146
+ getValue={(d, key) => d[key as 'shown']}
147
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
148
+ ariaLabel="Folded"
149
+ {...(cursorResolution !== undefined && { cursorResolution })}
150
+ />
151
+ </ChartCursorScope>,
152
+ )
153
+ }
154
+
155
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
156
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
157
+ function driveDailyToAug05() {
158
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
159
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
160
+ }
161
+
162
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
163
+ renderPair('leading')
164
+ driveDailyToAug05()
165
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
166
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
167
+ })
168
+
169
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
170
+ renderPair()
171
+ driveDailyToAug05()
172
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
173
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
174
+ })
175
+ })
176
+
92
177
  describe('BarsBar<T> — the `= unknown` default stays assignable', () => {
93
178
  test('an existing untyped BarsBar[] array is assignable to BarsBar<Row>[] contravariantly', () => {
94
179
  const bars: BarsBar[] = [{ key: 'shown', label: 'Shown', color: '#111' }]
@@ -1,6 +1,7 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
2
  import { LinePath } from '@visx/shape'
3
3
  import { memo, useMemo } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { VX } from '../../tokens'
5
6
  import type { ChartMargin } from '../../tokens'
6
7
  import { CartesianChart } from '../primitives/CartesianChart'
@@ -108,6 +109,14 @@ export type BarsProps<T> = {
108
109
 
109
110
  /** X tick count override. Default: as many as fit. */
110
111
  xTicks?: number
112
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
113
+ formatX?: (key: string) => string
114
+ /**
115
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
116
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
117
+ * keyed by its Monday) — see `CursorResolution`.
118
+ */
119
+ cursorResolution?: CursorResolution
111
120
 
112
121
  /** `false` disables the tooltip entirely (and with it the crosshair dots). */
113
122
  tooltip?: CartesianTooltipConfig<T> | false
@@ -152,6 +161,8 @@ function BarsInner<T>(props: BarsProps<T>) {
152
161
  barLayout = 'stacked',
153
162
  barOpacity,
154
163
  xTicks,
164
+ formatX,
165
+ cursorResolution,
155
166
  tooltip,
156
167
  margin,
157
168
  legend,
@@ -269,6 +280,8 @@ function BarsInner<T>(props: BarsProps<T>) {
269
280
  y={yConfig}
270
281
  {...(y2 !== undefined && { y2 })}
271
282
  {...(xTicks !== undefined && { xTicks })}
283
+ {...(formatX !== undefined && { formatX })}
284
+ {...(cursorResolution !== undefined && { cursorResolution })}
272
285
  {...(zones !== undefined && { zones })}
273
286
  {...(mappedRefLines !== undefined && { refLines: mappedRefLines })}
274
287
  {...(height !== undefined && { height })}
@@ -9,6 +9,8 @@
9
9
  import { fireEvent, render, screen } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
11
  import { renderToStaticMarkup } from 'react-dom/server'
12
+ import { ChartCursorScope } from '../cursor/scope'
13
+ import { CartesianChart } from '../primitives/CartesianChart'
12
14
  import { DualPanel } from './DualPanel'
13
15
  import type { ChartSeries } from '../series'
14
16
 
@@ -96,6 +98,236 @@ describe('DualPanel — per-point getMarker rendering', () => {
96
98
  })
97
99
  })
98
100
 
101
+ describe('DualPanel — getMarker fillOpacity/ring', () => {
102
+ test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
103
+ const markerRows: Row[] = [
104
+ { date: '2026-08-01', v: 10, bar: 0.02 },
105
+ { date: '2026-08-02', v: 12, bar: 0.03 },
106
+ ]
107
+ const markerSeries: ChartSeries<Row>[] = [
108
+ {
109
+ key: 'v',
110
+ label: 'V',
111
+ color: '#111',
112
+ mark: 'line',
113
+ getValue: (d) => d.v,
114
+ getMarker: (d) =>
115
+ d.date === '2026-08-01'
116
+ ? { ring: false, fillOpacity: 0.7 }
117
+ : d.date === '2026-08-02'
118
+ ? {}
119
+ : null,
120
+ },
121
+ ]
122
+
123
+ const { container } = render(
124
+ <DualPanel<Row>
125
+ data={markerRows}
126
+ chartId="dp-marker-ring"
127
+ getX={(d) => d.date}
128
+ series={markerSeries}
129
+ getBar={(d) => d.bar}
130
+ barLabel="Bar"
131
+ barColorPositive="#0a0"
132
+ barColorNegative="#a00"
133
+ formatTop={(v) => String(v)}
134
+ formatBottom={(v) => v.toFixed(2)}
135
+ />,
136
+ )
137
+
138
+ const circles = container.querySelectorAll('circle')
139
+ expect(circles).toHaveLength(2)
140
+ // ring: false — no stroke attribute at all.
141
+ expect(circles[0]?.getAttribute('stroke')).toBeNull()
142
+ expect(circles[0]?.getAttribute('fill-opacity')).toBe('0.7')
143
+ // default marker — unchanged punched-out ring + full opacity.
144
+ expect(circles[1]?.getAttribute('stroke')).not.toBeNull()
145
+ expect(circles[1]?.getAttribute('fill-opacity')).toBe('1')
146
+ })
147
+ })
148
+
149
+ describe('DualPanel — formatX', () => {
150
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
151
+ const { container } = render(
152
+ <DualPanel<Row>
153
+ data={rows}
154
+ chartId="dp-formatx"
155
+ getX={(d) => d.date}
156
+ series={series}
157
+ getBar={(d) => d.bar}
158
+ barLabel="Bar"
159
+ barColorPositive="#0a0"
160
+ barColorNegative="#a00"
161
+ formatTop={(v) => String(v)}
162
+ formatBottom={(v) => v.toFixed(2)}
163
+ formatX={(key) => `X:${key}`}
164
+ />,
165
+ )
166
+ expect(container.innerHTML).toContain('X:2026-08-01')
167
+ })
168
+ })
169
+
170
+ describe('DualPanel — formatX reaches the margin measurement, not just the axis', () => {
171
+ test('a formatter that widens x labels narrows the plot rect — the gutter grows to fit it', () => {
172
+ function xLabelGutterWidth(formatX?: (key: string) => string): number {
173
+ const { container, unmount } = render(
174
+ <DualPanel<Row>
175
+ data={rows}
176
+ chartId="dp-margin"
177
+ getX={(d) => d.date}
178
+ series={series}
179
+ getBar={(d) => d.bar}
180
+ barLabel="Bar"
181
+ barColorPositive="#0a0"
182
+ barColorNegative="#a00"
183
+ formatTop={(v) => String(v)}
184
+ formatBottom={(v) => v.toFixed(2)}
185
+ {...(formatX !== undefined && { formatX })}
186
+ />,
187
+ )
188
+ // The transparent HoverOverlay rects are drawn at exactly `width={xMax}` — `plot.width` is
189
+ // fixed under this DOM harness (no ResizeObserver, so `ChartFrame` falls back to its
190
+ // `minWidth` floor), so a narrower `xMax` here can only mean a wider measured margin.
191
+ const overlay = container.querySelector('rect[fill="transparent"]')
192
+ const width = Number(overlay?.getAttribute('width'))
193
+ unmount()
194
+ return width
195
+ }
196
+
197
+ const defaultWidth = xLabelGutterWidth()
198
+ const widenedWidth = xLabelGutterWidth(
199
+ (key) => `Extremely Long Formatted Axis Label ${key} With Lots Of Extra Padding Text`,
200
+ )
201
+ expect(widenedWidth).toBeLessThan(defaultWidth)
202
+ })
203
+ })
204
+
205
+ describe('DualPanel — tooltip.formatHeader', () => {
206
+ test('overrides the tooltip header date text', async () => {
207
+ render(
208
+ <DualPanel<Row>
209
+ data={rows}
210
+ chartId="dp-formatheader"
211
+ getX={(d) => d.date}
212
+ series={series}
213
+ getBar={(d) => d.bar}
214
+ barLabel="Bar"
215
+ barColorPositive="#0a0"
216
+ barColorNegative="#a00"
217
+ formatTop={(v) => String(v)}
218
+ formatBottom={(v) => v.toFixed(2)}
219
+ formatHeader={(key) => `hdr:${key}`}
220
+ />,
221
+ )
222
+
223
+ const slider = screen.getByRole('slider')
224
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
225
+
226
+ expect(await screen.findByText('hdr:2026-08-01')).toBeTruthy()
227
+ })
228
+ })
229
+
230
+ describe('DualPanel — ariaLabel reaches the slider, not only the frame', () => {
231
+ test('both the outer group and the focusable slider carry the name', () => {
232
+ render(
233
+ <DualPanel<Row>
234
+ data={rows}
235
+ chartId="dp-arialabel"
236
+ getX={(d) => d.date}
237
+ series={series}
238
+ getBar={(d) => d.bar}
239
+ barLabel="Bar"
240
+ barColorPositive="#0a0"
241
+ barColorNegative="#a00"
242
+ formatTop={(v) => String(v)}
243
+ formatBottom={(v) => v.toFixed(2)}
244
+ ariaLabel="Latency"
245
+ />,
246
+ )
247
+
248
+ // The bug was precisely that the label stopped at the frame — assert both carry it.
249
+ expect(screen.getByRole('group', { name: 'Latency' })).toBeTruthy()
250
+ expect(screen.getByRole('slider', { name: 'Latency' })).toBeTruthy()
251
+ })
252
+ })
253
+
254
+ describe('DualPanel — slider value attributes', () => {
255
+ test('aria-valuemax is always present; aria-valuenow/valuetext appear only once a point is hovered', () => {
256
+ render(
257
+ <DualPanel<Row>
258
+ data={rows}
259
+ chartId="dp-slider-values"
260
+ getX={(d) => d.date}
261
+ series={series}
262
+ getBar={(d) => d.bar}
263
+ barLabel="Bar"
264
+ barColorPositive="#0a0"
265
+ barColorNegative="#a00"
266
+ formatTop={(v) => String(v)}
267
+ formatBottom={(v) => v.toFixed(2)}
268
+ />,
269
+ )
270
+
271
+ const slider = screen.getByRole('slider')
272
+ expect(slider.getAttribute('aria-valuemax')).toBe('0')
273
+ expect(slider.hasAttribute('aria-valuenow')).toBe(false)
274
+ expect(slider.hasAttribute('aria-valuetext')).toBe(false)
275
+
276
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
277
+
278
+ expect(slider.getAttribute('aria-valuenow')).toBe('0')
279
+ expect(slider.getAttribute('aria-valuetext')).toBe('01.08')
280
+ })
281
+ })
282
+
283
+ describe('DualPanel — aria-valuetext reflects formatX', () => {
284
+ test('a custom formatX also drives the hovered slider value text, not just the axis', () => {
285
+ render(
286
+ <DualPanel<Row>
287
+ data={rows}
288
+ chartId="dp-slider-formatx"
289
+ getX={(d) => d.date}
290
+ series={series}
291
+ getBar={(d) => d.bar}
292
+ barLabel="Bar"
293
+ barColorPositive="#0a0"
294
+ barColorNegative="#a00"
295
+ formatTop={(v) => String(v)}
296
+ formatBottom={(v) => v.toFixed(2)}
297
+ formatX={(key) => `X:${key}`}
298
+ />,
299
+ )
300
+
301
+ const slider = screen.getByRole('slider')
302
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
303
+
304
+ expect(slider.getAttribute('aria-valuetext')).toBe('X:2026-08-01')
305
+ })
306
+ })
307
+
308
+ describe('DualPanel — only the top overlay is keyboard-focusable', () => {
309
+ test('renders exactly one role="slider" element — the bottom overlay stays pointer-only', () => {
310
+ render(
311
+ <DualPanel<Row>
312
+ data={rows}
313
+ chartId="dp-one-slider"
314
+ getX={(d) => d.date}
315
+ series={series}
316
+ getBar={(d) => d.bar}
317
+ barLabel="Bar"
318
+ barColorPositive="#0a0"
319
+ barColorNegative="#a00"
320
+ formatTop={(v) => String(v)}
321
+ formatBottom={(v) => v.toFixed(2)}
322
+ />,
323
+ )
324
+
325
+ // Two focusable tab stops for one logical x-axis is exactly what the pointer-only bottom
326
+ // overlay exists to prevent — this fails the moment a future edit adds onKeyDown there too.
327
+ expect(screen.getAllByRole('slider')).toHaveLength(1)
328
+ })
329
+ })
330
+
99
331
  describe('DualPanel — fixed bottomYDomain ships verbatim (un-niced)', () => {
100
332
  test('bottom axis ticks come from the pinned domain, not the computed max-abs', () => {
101
333
  // Data's max abs is 0.02 — an auto domain would never produce a "5.00" tick. Pinning
@@ -129,3 +361,81 @@ describe('DualPanel — formatBar overrides only the tooltip row', () => {
129
361
  expect(await screen.findByText('0.020 m/s (2026-08-01)')).toBeTruthy()
130
362
  })
131
363
  })
364
+
365
+ describe('DualPanel — cursorResolution threads through to sibling resolution', () => {
366
+ // `DualPanel` composes `ChartFrame` + `useChartCursor` directly rather than `CartesianChart`, so
367
+ // its forward runs through a different path than every other kind — verify it here rather than
368
+ // assuming the `CartesianChart`-composing pattern (`Bars.test.tsx`) ports verbatim.
369
+ //
370
+ // `DualPanel` forwards `ariaLabel` onto its own keyboard overlay (parity with every other kind),
371
+ // so the resolved bucket can be read straight off `aria-valuetext` — mirrors
372
+ // `Bars.test.tsx`'s cursorResolution assertions.
373
+ type DailyRow = { date: string; shown: number }
374
+ type FoldedRow = { date: string; v: number; bar: number }
375
+
376
+ const dailyRows: DailyRow[] = Array.from({ length: 14 }, (_, i) => ({
377
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
378
+ shown: i,
379
+ }))
380
+ const foldedRows: FoldedRow[] = [
381
+ { date: '2026-08-01', v: 0, bar: 5 },
382
+ { date: '2026-08-08', v: 1, bar: -5 },
383
+ ]
384
+ const dailySeries: ChartSeries<DailyRow>[] = [
385
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
386
+ ]
387
+ const foldedSeries: ChartSeries<FoldedRow>[] = [
388
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
389
+ ]
390
+
391
+ function renderPair(cursorResolution?: 'leading') {
392
+ render(
393
+ <ChartCursorScope>
394
+ <CartesianChart<DailyRow>
395
+ data={dailyRows}
396
+ chartId="dp-cursor-daily"
397
+ getX={(d) => d.date}
398
+ series={dailySeries}
399
+ ariaLabel="Daily"
400
+ >
401
+ {() => null}
402
+ </CartesianChart>
403
+ <DualPanel<FoldedRow>
404
+ data={foldedRows}
405
+ chartId="dp-cursor-folded"
406
+ getX={(d) => d.date}
407
+ series={foldedSeries}
408
+ getBar={(d) => d.bar}
409
+ barLabel="Bar"
410
+ barColorPositive="#0a0"
411
+ barColorNegative="#a00"
412
+ formatTop={(v) => String(v)}
413
+ formatBottom={(v) => v.toFixed(2)}
414
+ ariaLabel="Folded"
415
+ {...(cursorResolution !== undefined && { cursorResolution })}
416
+ />
417
+ </ChartCursorScope>,
418
+ )
419
+ }
420
+
421
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
422
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
423
+ function driveDailyToAug05() {
424
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
425
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
426
+ }
427
+
428
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
429
+ renderPair('leading')
430
+ driveDailyToAug05()
431
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
432
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
433
+ })
434
+
435
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
436
+ renderPair()
437
+ driveDailyToAug05()
438
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
439
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
440
+ })
441
+ })
@@ -18,6 +18,7 @@ import { Crosshair, SeriesDot } from '../primitives/Crosshair'
18
18
  import { HoverOverlay } from '../primitives/HoverOverlay'
19
19
  import { ZoneRects } from '../primitives/ZoneRects'
20
20
  import type { ZoneSpec } from '../primitives/ZoneRects'
21
+ import type { CursorResolution } from '../cursor/resolve'
21
22
  import { useChartCursor } from '../hooks/useChartCursor'
22
23
  import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
23
24
  import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
@@ -32,6 +33,15 @@ export type DualPanelProps<T> = {
32
33
  height?: number
33
34
  chartId: string
34
35
  getX: (d: T) => string
36
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM) — same default `CartesianChart` uses,
37
+ * so omitting it is byte-identical to today's rendering. */
38
+ formatX?: (key: string) => string
39
+ /**
40
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
41
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
42
+ * keyed by its Monday) — see `CursorResolution`.
43
+ */
44
+ cursorResolution?: CursorResolution
35
45
  /** 1+ line series in the top pane — the single source of truth for color, dash, legend, and
36
46
  * tooltip rows. E.g. acute (solid) / chronic (dashed). */
37
47
  series: ChartSeries<T>[]
@@ -67,6 +77,13 @@ export type DualPanelProps<T> = {
67
77
  bottomMaxAbsFloor?: number
68
78
  /** Tooltip badge — appears at the right of the tooltip header. */
69
79
  tooltipLabel?: (d: T) => { text: string; color: string } | null
80
+ /**
81
+ * Overrides the tooltip header's date text — the same seam `CartesianChart`'s
82
+ * `CartesianTooltipConfig.formatHeader` exposes, so the two don't diverge. Default:
83
+ * `TooltipHeader`'s own `fmtTooltipDate` behavior, unchanged. Receives the raw `getX` key
84
+ * alongside the hovered datum.
85
+ */
86
+ formatHeader?: (key: string, d: T) => string
70
87
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
71
88
  * Default `{ placement: 'bottom' }`. */
72
89
  legend?: ChartLegendConfig | false
@@ -138,6 +155,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
138
155
  data,
139
156
  chartId,
140
157
  getX,
158
+ formatX = fmtAxisDate,
159
+ cursorResolution,
160
+ ariaLabel,
141
161
  series,
142
162
  topYDomain = 'auto',
143
163
  fillBetween,
@@ -154,6 +174,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
154
174
  bottomYDomain = 'auto',
155
175
  bottomMaxAbsFloor,
156
176
  tooltipLabel,
177
+ formatHeader,
157
178
  plot,
158
179
  } = props
159
180
 
@@ -237,7 +258,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
237
258
 
238
259
  // Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
239
260
  // labels, never widens the max, so measuring against the full set is a safe over-estimate.
240
- const xLabelsAll = useMemo(() => data.map((d) => fmtAxisDate(getX(d))), [data, getX])
261
+ // Uses the SAME `formatX` the real axis paints with (`AxisBottomDate` below), or the measured
262
+ // gutter would be computed from labels the axis never paints.
263
+ const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
241
264
 
242
265
  const margin = useMemo(
243
266
  () => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
@@ -285,6 +308,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
285
308
  getKey: getX,
286
309
  xScale,
287
310
  marginLeft: margin.left,
311
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
288
312
  })
289
313
 
290
314
  const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
@@ -382,6 +406,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
382
406
  if (m === null) continue
383
407
  const cx = xScale(getX(p.__d)) ?? 0
384
408
  const cy = topYScale(p.__y)
409
+ const ring = m.ring ?? true
385
410
  markers.push(
386
411
  <circle
387
412
  key={`mk-${s.key}-${getX(p.__d)}`}
@@ -389,8 +414,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
389
414
  cy={cy}
390
415
  r={m.r ?? VX.dotR}
391
416
  fill={m.color ?? s.color}
392
- stroke={VX.dotStroke}
393
- strokeWidth={2}
417
+ fillOpacity={m.fillOpacity ?? 1}
418
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 2 })}
394
419
  />,
395
420
  )
396
421
  }
@@ -484,6 +509,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
484
509
  {/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
485
510
  x-only, so either overlay yields the same point. The top overlay extends over the gutter
486
511
  (height = topH + PANE_GAP) so there's no dead zone between the panes. */}
512
+ {/* The focusable overlay is the ONE keyboard control for both panes, so it carries the
513
+ slider's label and value — parity with `CartesianChart`. Without them the frame's
514
+ `aria-label` announces the chart and the slider underneath it is an unnamed control
515
+ reporting no position: technically focusable, useless to read. The bottom overlay
516
+ stays pointer-only and therefore deliberately carries neither. */}
487
517
  <HoverOverlay
488
518
  width={xMax}
489
519
  height={topH + PANE_GAP}
@@ -491,6 +521,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
491
521
  onLeave={cursor.onPointerLeave}
492
522
  onKeyDown={cursor.onKeyDown}
493
523
  onBlur={cursor.onBlur}
524
+ valueMax={Math.max(data.length - 1, 0)}
525
+ {...(point !== null && {
526
+ valueNow: data.indexOf(point),
527
+ valueText: formatX(getX(point)),
528
+ })}
529
+ {...(ariaLabel !== undefined && { ariaLabel })}
494
530
  />
495
531
  </Group>
496
532
 
@@ -531,7 +567,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
531
567
  )}
532
568
 
533
569
  <AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
534
- <AxisBottomDate top={bottomH} scale={xScale} tickValues={tickValues} />
570
+ <AxisBottomDate
571
+ top={bottomH}
572
+ scale={xScale}
573
+ tickValues={tickValues}
574
+ tickFormat={formatX}
575
+ />
535
576
  {/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
536
577
  Both panes scrub the same single x cursor, so a second tab stop would be a second
537
578
  control for one logical axis: a keyboard user would tab twice through one chart and
@@ -556,6 +597,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
556
597
  <ChartTooltipFloat anchor={cursor.anchor}>
557
598
  <TooltipHeader
558
599
  date={getX(point)}
600
+ {...(formatHeader !== undefined && {
601
+ format: (key: string) => formatHeader(key, point),
602
+ })}
559
603
  {...(() => {
560
604
  const lbl = tooltipLabel?.(point) ?? null
561
605
  return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}