basalt-ui 1.15.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 (91) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +74 -13
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  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 +2 -1
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +30 -8
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +10 -3
  15. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  16. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  17. package/dist/charts/kinds/DualPanel.d.ts +30 -0
  18. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  19. package/dist/charts/kinds/DualPanel.js +82 -12
  20. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  21. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  22. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  23. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  24. package/dist/charts/kinds/MultiLine.js +45 -33
  25. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  26. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/StackedArea.js +16 -1
  30. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  31. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  32. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  33. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  34. package/dist/charts/kinds/ZonedLine.js +5 -0
  35. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  36. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  37. package/dist/charts/primitives/CartesianChart.d.ts +45 -6
  38. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  39. package/dist/charts/primitives/CartesianChart.js +29 -12
  40. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  41. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  42. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  43. package/dist/charts/primitives/ChartFrame.js +1 -1
  44. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  45. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  46. package/dist/charts/primitives/ChartLegend.js +4 -0
  47. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  48. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  49. package/dist/charts/primitives/ChartTooltip.js +3 -2
  50. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  51. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  52. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  53. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  54. package/dist/charts/primitives/XZoneRects.js +39 -2
  55. package/dist/charts/series.d.ts +16 -3
  56. package/dist/charts/series.d.ts.map +1 -1
  57. package/dist/charts/series.js +2 -1
  58. package/dist/charts/utils/domain.d.ts +15 -0
  59. package/dist/charts/utils/domain.d.ts.map +1 -1
  60. package/dist/charts/utils/domain.js +6 -1
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/cursor/resolve.test.ts +62 -0
  64. package/src/charts/cursor/resolve.ts +71 -7
  65. package/src/charts/hooks/useChartCursor.ts +12 -1
  66. package/src/charts/index.ts +2 -0
  67. package/src/charts/kinds/Bars.test.tsx +183 -0
  68. package/src/charts/kinds/Bars.tsx +41 -12
  69. package/src/charts/kinds/DualPanel.test.tsx +441 -0
  70. package/src/charts/kinds/DualPanel.tsx +127 -11
  71. package/src/charts/kinds/MultiLine.test.tsx +218 -0
  72. package/src/charts/kinds/MultiLine.tsx +64 -36
  73. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  74. package/src/charts/kinds/StackedArea.tsx +25 -1
  75. package/src/charts/kinds/ZonedLine.test.tsx +147 -0
  76. package/src/charts/kinds/ZonedLine.tsx +14 -0
  77. package/src/charts/primitives/CartesianChart.test.tsx +269 -0
  78. package/src/charts/primitives/CartesianChart.tsx +79 -19
  79. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  80. package/src/charts/primitives/ChartFrame.tsx +9 -2
  81. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  82. package/src/charts/primitives/ChartLegend.tsx +7 -0
  83. package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
  84. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  85. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  86. package/src/charts/primitives/XZoneRects.tsx +75 -10
  87. package/src/charts/series.test.ts +51 -0
  88. package/src/charts/series.ts +18 -6
  89. package/src/charts/utils/domain.test.ts +31 -1
  90. package/src/charts/utils/domain.ts +19 -0
  91. package/src/charts/primitives/CartesianChart.test.ts +0 -99
@@ -0,0 +1,441 @@
1
+ /**
2
+ * `DualPanel` — the bottom-pane domain seam (`bottomYDomain`/`bottomMaxAbsFloor`) and `formatBar`
3
+ * (tooltip-row-only override, `formatBottom` keeps owning the ticks). Same SSR harness as
4
+ * `ChartFrame.test.tsx` for the axis-only assertions (no hover needed); the `formatBar` case needs
5
+ * a live cursor, so it uses the real DOM harness + the overlay's keyboard path (parity with
6
+ * `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
7
+ * keyboard-focusable, so `getByRole('slider')` is unambiguous.
8
+ */
9
+ import { fireEvent, render, screen } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { renderToStaticMarkup } from 'react-dom/server'
12
+ import { ChartCursorScope } from '../cursor/scope'
13
+ import { CartesianChart } from '../primitives/CartesianChart'
14
+ import { DualPanel } from './DualPanel'
15
+ import type { ChartSeries } from '../series'
16
+
17
+ type Row = { date: string; v: number; bar: number }
18
+
19
+ const rows: Row[] = [{ date: '2026-08-01', v: 10, bar: 0.02 }]
20
+
21
+ const series: ChartSeries<Row>[] = [
22
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
23
+ ]
24
+
25
+ function renderStatic(bottomMaxAbsFloor?: number, bottomYDomain?: [number, number]): string {
26
+ return renderToStaticMarkup(
27
+ <DualPanel<Row>
28
+ data={rows}
29
+ chartId="dp-floor"
30
+ getX={(d) => d.date}
31
+ series={series}
32
+ getBar={(d) => d.bar}
33
+ barLabel="Bar"
34
+ barColorPositive="#0a0"
35
+ barColorNegative="#a00"
36
+ formatTop={(v) => String(v)}
37
+ formatBottom={(v) => v.toFixed(2)}
38
+ {...(bottomMaxAbsFloor !== undefined && { bottomMaxAbsFloor })}
39
+ {...(bottomYDomain !== undefined && { bottomYDomain })}
40
+ />,
41
+ )
42
+ }
43
+
44
+ describe('DualPanel — bottomMaxAbsFloor', () => {
45
+ test('floors the symmetric max-abs so a near-zero plateau does not amplify to full height', () => {
46
+ // The raw data's max abs is 0.02 — nowhere near the 0.5 floor, so "0.50" can only appear on
47
+ // the bottom axis once the floor widens the domain.
48
+ expect(renderStatic()).not.toContain('>0.50<')
49
+ expect(renderStatic(0.5)).toContain('>0.50<')
50
+ })
51
+ })
52
+
53
+ describe('DualPanel — per-point getMarker rendering', () => {
54
+ test('renders exactly one circle per non-null marker, honouring color/r overrides', () => {
55
+ const markerRows: Row[] = [
56
+ { date: '2026-08-01', v: 10, bar: 0.02 },
57
+ { date: '2026-08-02', v: 12, bar: 0.03 },
58
+ { date: '2026-08-03', v: 9, bar: 0.01 },
59
+ ]
60
+ const markerSeries: ChartSeries<Row>[] = [
61
+ {
62
+ key: 'v',
63
+ label: 'V',
64
+ color: '#111',
65
+ mark: 'line',
66
+ getValue: (d) => d.v,
67
+ getMarker: (d) =>
68
+ d.date === '2026-08-01'
69
+ ? { color: '#f00', r: 9 }
70
+ : d.date === '2026-08-03'
71
+ ? { r: 3 }
72
+ : null,
73
+ },
74
+ ]
75
+
76
+ const { container } = render(
77
+ <DualPanel<Row>
78
+ data={markerRows}
79
+ chartId="dp-markers"
80
+ getX={(d) => d.date}
81
+ series={markerSeries}
82
+ getBar={(d) => d.bar}
83
+ barLabel="Bar"
84
+ barColorPositive="#0a0"
85
+ barColorNegative="#a00"
86
+ formatTop={(v) => String(v)}
87
+ formatBottom={(v) => v.toFixed(2)}
88
+ />,
89
+ )
90
+
91
+ const circles = container.querySelectorAll('circle')
92
+ expect(circles).toHaveLength(2)
93
+ expect(circles[0]?.getAttribute('fill')).toBe('#f00')
94
+ expect(circles[0]?.getAttribute('r')).toBe('9')
95
+ // No color override on the second marker — falls back to the series color.
96
+ expect(circles[1]?.getAttribute('fill')).toBe('#111')
97
+ expect(circles[1]?.getAttribute('r')).toBe('3')
98
+ })
99
+ })
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
+
331
+ describe('DualPanel — fixed bottomYDomain ships verbatim (un-niced)', () => {
332
+ test('bottom axis ticks come from the pinned domain, not the computed max-abs', () => {
333
+ // Data's max abs is 0.02 — an auto domain would never produce a "5.00" tick. Pinning
334
+ // bottomYDomain to [-5, 5] must still paint it.
335
+ expect(renderStatic(undefined, [-5, 5])).toContain('>5.00<')
336
+ expect(renderStatic()).not.toContain('>5.00<')
337
+ })
338
+ })
339
+
340
+ describe('DualPanel — formatBar overrides only the tooltip row', () => {
341
+ test('the bar tooltip row reads formatBar, not formatBottom', async () => {
342
+ render(
343
+ <DualPanel<Row>
344
+ data={rows}
345
+ chartId="dp-formatbar"
346
+ getX={(d) => d.date}
347
+ series={series}
348
+ getBar={(d) => d.bar}
349
+ barLabel="Bar"
350
+ barColorPositive="#0a0"
351
+ barColorNegative="#a00"
352
+ formatTop={(v) => String(v)}
353
+ formatBottom={(v) => v.toFixed(2)}
354
+ formatBar={(v, d) => `${v.toFixed(3)} m/s (${d.date})`}
355
+ />,
356
+ )
357
+
358
+ const slider = screen.getByRole('slider')
359
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
360
+
361
+ expect(await screen.findByText('0.020 m/s (2026-08-01)')).toBeTruthy()
362
+ })
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
+ })