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.
- package/CHANGELOG.md +7 -0
- package/agent/rules/basalt-charts.md +46 -4
- 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 +17 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +29 -6
- 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 +23 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +11 -4
- 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 +6 -1
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +3 -2
- 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 +4 -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 +310 -0
- package/src/charts/kinds/DualPanel.tsx +48 -4
- 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 +111 -2
- package/src/charts/primitives/CartesianChart.tsx +35 -7
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/series.ts +3 -2
- package/src/charts/utils/domain.test.ts +31 -1
- 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(
|
|
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)
|
package/src/charts/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
393
|
-
strokeWidth
|
|
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
|
|
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 } : {}
|