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.
- package/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +58 -4
- package/agent/skills/basalt-design/SKILL.md +2 -2
- package/dist/charts/cursor/resolve.d.ts +29 -5
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +25 -4
- package/dist/charts/hooks/useChartCursor.d.ts +8 -1
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +6 -2
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +9 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +4 -0
- package/dist/charts/kinds/DualPanel.d.ts +24 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +39 -9
- package/dist/charts/kinds/MultiLine.d.ts +9 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +10 -8
- package/dist/charts/kinds/StackedArea.d.ts +9 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +16 -1
- package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
- package/dist/charts/kinds/ZonedLine.d.ts +9 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +4 -0
- package/dist/charts/primitives/CartesianChart.d.ts +40 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +16 -6
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +1 -1
- package/dist/charts/primitives/ChartTooltip.d.ts +12 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +5 -3
- package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
- package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
- package/dist/charts/series.d.ts +9 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/domain.d.ts +15 -0
- package/dist/charts/utils/domain.d.ts.map +1 -1
- package/dist/charts/utils/domain.js +6 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +62 -0
- package/src/charts/cursor/resolve.ts +71 -7
- package/src/charts/hooks/useChartCursor.ts +12 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/Bars.test.tsx +85 -0
- package/src/charts/kinds/Bars.tsx +13 -0
- package/src/charts/kinds/DualPanel.test.tsx +391 -1
- package/src/charts/kinds/DualPanel.tsx +74 -8
- package/src/charts/kinds/MultiLine.test.tsx +181 -0
- package/src/charts/kinds/MultiLine.tsx +19 -8
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +110 -0
- package/src/charts/kinds/ZonedLine.tsx +13 -0
- package/src/charts/primitives/CartesianChart.test.tsx +284 -3
- package/src/charts/primitives/CartesianChart.tsx +64 -12
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartTooltip.test.tsx +44 -0
- package/src/charts/primitives/ChartTooltip.tsx +14 -2
- package/src/charts/series.ts +8 -2
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
|
|
7
7
|
* keyboard-focusable, so `getByRole('slider')` is unambiguous.
|
|
8
8
|
*/
|
|
9
|
-
import { fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
10
10
|
import { describe, expect, test } from 'bun:test'
|
|
11
11
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
12
|
+
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,161 @@ 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
|
+
})
|
|
442
|
+
|
|
443
|
+
describe('DualPanel — onFollow (follower behaviour parity with CartesianChart)', () => {
|
|
444
|
+
type SourceRow = { date: string; shown: number }
|
|
445
|
+
const sourceRows: SourceRow[] = [{ date: '2026-08-01', shown: 10 }]
|
|
446
|
+
const sourceSeries: ChartSeries<SourceRow>[] = [
|
|
447
|
+
{ key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
function renderPair(onFollow?: boolean, followerRows: Row[] = rows) {
|
|
451
|
+
render(
|
|
452
|
+
<ChartCursorScope>
|
|
453
|
+
<CartesianChart<SourceRow>
|
|
454
|
+
data={sourceRows}
|
|
455
|
+
chartId="dp-of-source"
|
|
456
|
+
getX={(d) => d.date}
|
|
457
|
+
series={sourceSeries}
|
|
458
|
+
ariaLabel="Source"
|
|
459
|
+
legend={false}
|
|
460
|
+
>
|
|
461
|
+
{() => null}
|
|
462
|
+
</CartesianChart>
|
|
463
|
+
<DualPanel<Row>
|
|
464
|
+
data={followerRows}
|
|
465
|
+
chartId="dp-of-follower"
|
|
466
|
+
getX={(d) => d.date}
|
|
467
|
+
series={series}
|
|
468
|
+
getBar={(d) => d.bar}
|
|
469
|
+
barLabel="Bar"
|
|
470
|
+
barColorPositive="#0a0"
|
|
471
|
+
barColorNegative="#a00"
|
|
472
|
+
formatTop={(v) => String(v)}
|
|
473
|
+
formatBottom={(v) => v.toFixed(2)}
|
|
474
|
+
ariaLabel="Follower"
|
|
475
|
+
legend={false}
|
|
476
|
+
{...(onFollow !== undefined && { onFollow })}
|
|
477
|
+
/>
|
|
478
|
+
</ChartCursorScope>,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
test('onFollow: true renders the DualPanel tooltip while a sibling owns the cursor', async () => {
|
|
483
|
+
renderPair(true)
|
|
484
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
485
|
+
|
|
486
|
+
expect(await screen.findByText('V')).toBeTruthy()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('without onFollow (default false), DualPanel renders no tooltip as a follower', async () => {
|
|
490
|
+
renderPair()
|
|
491
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
492
|
+
|
|
493
|
+
// The SOURCE still renders its own tooltip — only the DualPanel follower stays silent.
|
|
494
|
+
expect(await screen.findByText('Shown')).toBeTruthy()
|
|
495
|
+
expect(screen.queryByText('V')).toBeNull()
|
|
496
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('the follower tooltip is not aria-live; the source is', async () => {
|
|
500
|
+
renderPair(true)
|
|
501
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
502
|
+
await screen.findByText('V')
|
|
503
|
+
|
|
504
|
+
const tooltips = screen.getAllByRole('tooltip')
|
|
505
|
+
expect(tooltips).toHaveLength(2)
|
|
506
|
+
const sourceTooltip = tooltips.find((t) => within(t).queryByText('Shown') !== null)
|
|
507
|
+
const followerTooltip = tooltips.find((t) => within(t).queryByText('V') !== null)
|
|
508
|
+
expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
|
|
509
|
+
expect(followerTooltip?.hasAttribute('aria-live')).toBe(false)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('a follower whose domain never resolves the broadcast key renders no tooltip shell', async () => {
|
|
513
|
+
const foreignRows: Row[] = [{ date: '2027-01-15', v: 99, bar: 0.5 }]
|
|
514
|
+
renderPair(true, foreignRows)
|
|
515
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
516
|
+
|
|
517
|
+
expect(await screen.findByText('Shown')).toBeTruthy()
|
|
518
|
+
expect(screen.queryByText('V')).toBeNull()
|
|
519
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
520
|
+
})
|
|
521
|
+
})
|
|
@@ -4,7 +4,7 @@ import { Group } from '@visx/group'
|
|
|
4
4
|
import { scaleLinear, scalePoint } from '@visx/scale'
|
|
5
5
|
import { Bar, LinePath } from '@visx/shape'
|
|
6
6
|
import { Threshold } from '@visx/threshold'
|
|
7
|
-
import { memo, useMemo } from 'react'
|
|
7
|
+
import { memo, useMemo, useRef } from 'react'
|
|
8
8
|
import type { ReactNode } from 'react'
|
|
9
9
|
import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
|
|
10
10
|
import {
|
|
@@ -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,20 @@ 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
|
|
87
|
+
/**
|
|
88
|
+
* Render this chart's tooltip when it is a cursor FOLLOWER, not only when it is the SOURCE —
|
|
89
|
+
* same seam and same anchored-positioning behaviour as `CartesianTooltipConfig.onFollow`. Default
|
|
90
|
+
* false (today's behaviour). Only the SOURCE's tooltip is `aria-live`; a follower's is visual
|
|
91
|
+
* only.
|
|
92
|
+
*/
|
|
93
|
+
onFollow?: boolean
|
|
70
94
|
/** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
|
|
71
95
|
* Default `{ placement: 'bottom' }`. */
|
|
72
96
|
legend?: ChartLegendConfig | false
|
|
@@ -138,6 +162,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
138
162
|
data,
|
|
139
163
|
chartId,
|
|
140
164
|
getX,
|
|
165
|
+
formatX = fmtAxisDate,
|
|
166
|
+
cursorResolution,
|
|
167
|
+
ariaLabel,
|
|
141
168
|
series,
|
|
142
169
|
topYDomain = 'auto',
|
|
143
170
|
fillBetween,
|
|
@@ -154,6 +181,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
154
181
|
bottomYDomain = 'auto',
|
|
155
182
|
bottomMaxAbsFloor,
|
|
156
183
|
tooltipLabel,
|
|
184
|
+
formatHeader,
|
|
185
|
+
onFollow,
|
|
157
186
|
plot,
|
|
158
187
|
} = props
|
|
159
188
|
|
|
@@ -237,7 +266,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
237
266
|
|
|
238
267
|
// Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
|
|
239
268
|
// labels, never widens the max, so measuring against the full set is a safe over-estimate.
|
|
240
|
-
|
|
269
|
+
// Uses the SAME `formatX` the real axis paints with (`AxisBottomDate` below), or the measured
|
|
270
|
+
// gutter would be computed from labels the axis never paints.
|
|
271
|
+
const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
|
|
241
272
|
|
|
242
273
|
const margin = useMemo(
|
|
243
274
|
() => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
|
|
@@ -285,6 +316,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
285
316
|
getKey: getX,
|
|
286
317
|
xScale,
|
|
287
318
|
marginLeft: margin.left,
|
|
319
|
+
...(cursorResolution !== undefined && { resolution: cursorResolution }),
|
|
288
320
|
})
|
|
289
321
|
|
|
290
322
|
const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
|
|
@@ -382,6 +414,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
382
414
|
if (m === null) continue
|
|
383
415
|
const cx = xScale(getX(p.__d)) ?? 0
|
|
384
416
|
const cy = topYScale(p.__y)
|
|
417
|
+
const ring = m.ring ?? true
|
|
385
418
|
markers.push(
|
|
386
419
|
<circle
|
|
387
420
|
key={`mk-${s.key}-${getX(p.__d)}`}
|
|
@@ -389,8 +422,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
389
422
|
cy={cy}
|
|
390
423
|
r={m.r ?? VX.dotR}
|
|
391
424
|
fill={m.color ?? s.color}
|
|
392
|
-
|
|
393
|
-
strokeWidth
|
|
425
|
+
fillOpacity={m.fillOpacity ?? 1}
|
|
426
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 2 })}
|
|
394
427
|
/>,
|
|
395
428
|
)
|
|
396
429
|
}
|
|
@@ -399,13 +432,27 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
399
432
|
[visibleSeries, lineValid, xScale, topYScale, getX],
|
|
400
433
|
)
|
|
401
434
|
|
|
435
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
402
436
|
const point = cursor.point
|
|
403
437
|
const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
|
|
404
438
|
const syncedBar = point !== null ? getBar(point) : null
|
|
405
439
|
|
|
440
|
+
// Same seam as `CartesianChart`: this chart renders its tooltip as the cursor SOURCE (always) or,
|
|
441
|
+
// opted in via `onFollow`, as a FOLLOWER. A follower has no pointer under it, so it is always
|
|
442
|
+
// positioned via the anchored (crosshair) path — `cursor.anchor` stays null on a chart nobody is
|
|
443
|
+
// hovering, so following it here would render nothing.
|
|
444
|
+
const isFollowerRender = !cursor.isSource && onFollow === true
|
|
445
|
+
const showTooltip = point !== null && (cursor.isSource || isFollowerRender)
|
|
446
|
+
const anchorX = isFollowerRender && point !== null ? sx : null
|
|
447
|
+
const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
|
|
448
|
+
const tooltipAnchor =
|
|
449
|
+
svgRect === undefined || anchorX === null
|
|
450
|
+
? cursor.anchor
|
|
451
|
+
: { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top }
|
|
452
|
+
|
|
406
453
|
return (
|
|
407
454
|
<>
|
|
408
|
-
<svg width={plot.width} height={plot.height}>
|
|
455
|
+
<svg ref={svgRef} width={plot.width} height={plot.height}>
|
|
409
456
|
{/* Top pane: line series + fill-between + zones + ref lines. */}
|
|
410
457
|
<Group left={margin.left} top={margin.top}>
|
|
411
458
|
<GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
|
|
@@ -484,6 +531,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
484
531
|
{/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
|
|
485
532
|
x-only, so either overlay yields the same point. The top overlay extends over the gutter
|
|
486
533
|
(height = topH + PANE_GAP) so there's no dead zone between the panes. */}
|
|
534
|
+
{/* The focusable overlay is the ONE keyboard control for both panes, so it carries the
|
|
535
|
+
slider's label and value — parity with `CartesianChart`. Without them the frame's
|
|
536
|
+
`aria-label` announces the chart and the slider underneath it is an unnamed control
|
|
537
|
+
reporting no position: technically focusable, useless to read. The bottom overlay
|
|
538
|
+
stays pointer-only and therefore deliberately carries neither. */}
|
|
487
539
|
<HoverOverlay
|
|
488
540
|
width={xMax}
|
|
489
541
|
height={topH + PANE_GAP}
|
|
@@ -491,6 +543,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
491
543
|
onLeave={cursor.onPointerLeave}
|
|
492
544
|
onKeyDown={cursor.onKeyDown}
|
|
493
545
|
onBlur={cursor.onBlur}
|
|
546
|
+
valueMax={Math.max(data.length - 1, 0)}
|
|
547
|
+
{...(point !== null && {
|
|
548
|
+
valueNow: data.indexOf(point),
|
|
549
|
+
valueText: formatX(getX(point)),
|
|
550
|
+
})}
|
|
551
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
494
552
|
/>
|
|
495
553
|
</Group>
|
|
496
554
|
|
|
@@ -531,7 +589,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
531
589
|
)}
|
|
532
590
|
|
|
533
591
|
<AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
|
|
534
|
-
<AxisBottomDate
|
|
592
|
+
<AxisBottomDate
|
|
593
|
+
top={bottomH}
|
|
594
|
+
scale={xScale}
|
|
595
|
+
tickValues={tickValues}
|
|
596
|
+
tickFormat={formatX}
|
|
597
|
+
/>
|
|
535
598
|
{/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
|
|
536
599
|
Both panes scrub the same single x cursor, so a second tab stop would be a second
|
|
537
600
|
control for one logical axis: a keyboard user would tab twice through one chart and
|
|
@@ -552,10 +615,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
552
615
|
)}
|
|
553
616
|
</svg>
|
|
554
617
|
|
|
555
|
-
{
|
|
556
|
-
<ChartTooltipFloat anchor={cursor.
|
|
618
|
+
{showTooltip && (
|
|
619
|
+
<ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
|
|
557
620
|
<TooltipHeader
|
|
558
621
|
date={getX(point)}
|
|
622
|
+
{...(formatHeader !== undefined && {
|
|
623
|
+
format: (key: string) => formatHeader(key, point),
|
|
624
|
+
})}
|
|
559
625
|
{...(() => {
|
|
560
626
|
const lbl = tooltipLabel?.(point) ?? null
|
|
561
627
|
return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
|