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 { fireEvent, render, screen } from '@testing-library/react'
|
|
2
2
|
import { describe, expect, test } from 'bun:test'
|
|
3
3
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
4
5
|
import type { ChartSeries } from '../series'
|
|
5
6
|
import { CartesianChart, resolveAxisDomain } from './CartesianChart'
|
|
6
7
|
|
|
@@ -44,9 +45,27 @@ describe('resolveAxisDomain', () => {
|
|
|
44
45
|
expect(min).toBeLessThanOrEqual(-40)
|
|
45
46
|
})
|
|
46
47
|
|
|
47
|
-
test('autoMaxFloor raises a low upper bound', () => {
|
|
48
|
+
test('autoMaxFloor raises a low upper bound, then pads it (mirrors autoMinCeil)', () => {
|
|
49
|
+
// The floor clamps the RAW upper bound first (44 -> 500), padding applies after: 500 * 1.1.
|
|
48
50
|
const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
|
|
49
|
-
expect(max).
|
|
51
|
+
expect(max).toBeCloseTo(550, 5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('autoMaxFloor at exactly the padded case (dataMax 3.2, pad 1.1, floor 6) yields 6.6', () => {
|
|
55
|
+
const single: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
|
|
56
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, single)
|
|
57
|
+
expect(max).toBeCloseTo(6.6, 5)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('autoMaxFloor and autoMinCeil pad symmetrically — both clamp first, pad second', () => {
|
|
61
|
+
// Upper: floor 6 clamps 3.2 -> 6, then pads to 6.6 (multiply, away from zero).
|
|
62
|
+
const positive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
|
|
63
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, positive)
|
|
64
|
+
expect(max).toBeCloseTo(6.6, 5)
|
|
65
|
+
// Lower: ceil -6 clamps -3.2 -> -6, then pads to -6.6 (multiply, away from zero) — the mirror.
|
|
66
|
+
const negative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => -3.2 }]
|
|
67
|
+
const [min] = resolveAxisDomain({ autoMinCeil: -6, autoPad: 1.1 }, rows, negative)
|
|
68
|
+
expect(min).toBeCloseTo(-6.6, 5)
|
|
50
69
|
})
|
|
51
70
|
|
|
52
71
|
test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
|
|
@@ -69,6 +88,33 @@ describe('resolveAxisDomain', () => {
|
|
|
69
88
|
})
|
|
70
89
|
})
|
|
71
90
|
|
|
91
|
+
describe('resolveAxisDomain — autoMaxFloor composed with an all-negative series', () => {
|
|
92
|
+
// getValue -> -d.a - 20 over rows a=[10, 40, 25] yields [-30, -60, -45]: data max is -30, data
|
|
93
|
+
// min is -60 — deliberately NOT a flat series, so this exercises the real clamp-then-pad path
|
|
94
|
+
// rather than the degenerate flat-collapse branch covered separately below.
|
|
95
|
+
const allNegative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => -d.a - 20 }]
|
|
96
|
+
|
|
97
|
+
test('a floor above the data max clamps to the floor, then pads TOWARD zero', () => {
|
|
98
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
|
|
99
|
+
// clamp: Math.max(-30, -10) = -10; padAutoUpper divides for a negative candidate (toward zero).
|
|
100
|
+
expect(max).toBeCloseTo(-10 / 1.1, 10)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('without a floor, the existing all-negative behavior is unchanged', () => {
|
|
104
|
+
const [, max] = resolveAxisDomain(undefined, rows, allNegative)
|
|
105
|
+
// no floor -> candidate stays the raw data max, -30.
|
|
106
|
+
expect(max).toBeCloseTo(-30 / 1.1, 10)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('autoMaxFloor / autoMinCeil clamp-then-pad symmetrically through resolveAxisDomain', () => {
|
|
110
|
+
// Mirror series: same magnitudes, positive instead of negative (data min 30, not data max -30).
|
|
111
|
+
const allPositive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => d.a + 20 }]
|
|
112
|
+
const [, maxFromFloor] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
|
|
113
|
+
const [minFromCeil] = resolveAxisDomain({ autoMinCeil: 10 }, rows, allPositive)
|
|
114
|
+
expect(maxFromFloor).toBeCloseTo(-minFromCeil, 10)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
72
118
|
describe('resolveAxisDomain — overlay bounds', () => {
|
|
73
119
|
test('a zone past the data stretches the axis instead of clipping', () => {
|
|
74
120
|
const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
|
|
@@ -127,6 +173,69 @@ describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', ()
|
|
|
127
173
|
})
|
|
128
174
|
})
|
|
129
175
|
|
|
176
|
+
describe('cursorResolution — threads through to sibling resolution (CartesianChart)', () => {
|
|
177
|
+
// Daily calendar Aug 01–14, plus a sibling folded into 2 weekly buckets keyed by each week's
|
|
178
|
+
// leading day (Aug 01, Aug 08) — the exact shape of the playground's "Weekly digest" pairing.
|
|
179
|
+
const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
|
|
180
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
181
|
+
a: i,
|
|
182
|
+
b: i,
|
|
183
|
+
}))
|
|
184
|
+
const foldedRows: Row[] = [
|
|
185
|
+
{ date: '2026-08-01', a: 0, b: 0 },
|
|
186
|
+
{ date: '2026-08-08', a: 1, b: 1 },
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
190
|
+
render(
|
|
191
|
+
<ChartCursorScope>
|
|
192
|
+
<CartesianChart<Row>
|
|
193
|
+
data={dailyRows}
|
|
194
|
+
chartId="daily"
|
|
195
|
+
getX={(d) => d.date}
|
|
196
|
+
series={[seriesFor('a')]}
|
|
197
|
+
ariaLabel="Daily"
|
|
198
|
+
>
|
|
199
|
+
{() => null}
|
|
200
|
+
</CartesianChart>
|
|
201
|
+
<CartesianChart<Row>
|
|
202
|
+
data={foldedRows}
|
|
203
|
+
chartId="folded"
|
|
204
|
+
getX={(d) => d.date}
|
|
205
|
+
series={[seriesFor('a')]}
|
|
206
|
+
ariaLabel="Folded"
|
|
207
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
208
|
+
>
|
|
209
|
+
{() => null}
|
|
210
|
+
</CartesianChart>
|
|
211
|
+
</ChartCursorScope>,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (its midpoint is
|
|
216
|
+
// Aug 04) — via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
217
|
+
function driveDailyToAug05() {
|
|
218
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
219
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
223
|
+
renderPair('leading')
|
|
224
|
+
driveDailyToAug05()
|
|
225
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
226
|
+
// Aug 05 is inside the Aug01-07 bucket — 'leading' must resolve to its leading key, Aug 01.
|
|
227
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket — proves cursorResolution actually threads through, not just the resolver it wraps", () => {
|
|
231
|
+
renderPair()
|
|
232
|
+
driveDailyToAug05()
|
|
233
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
234
|
+
// Aug 05 is closer to Aug 08 (distance 3) than Aug 01 (distance 4) — 'nearest' picks Aug 08.
|
|
235
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
130
239
|
describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
|
|
131
240
|
test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
|
|
132
241
|
render(
|
|
@@ -5,11 +5,12 @@ import { useMemo, useRef, useState } from 'react'
|
|
|
5
5
|
import type { ReactNode } from 'react'
|
|
6
6
|
import { VX } from '../../tokens'
|
|
7
7
|
import type { ChartMargin } from '../../tokens'
|
|
8
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
8
9
|
import { useChartCursor } from '../hooks/useChartCursor'
|
|
9
10
|
import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
|
|
10
11
|
import { deriveTooltipRows } from '../series'
|
|
11
12
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
12
|
-
import { padAutoLower } from '../utils/domain'
|
|
13
|
+
import { padAutoLower, padAutoUpper } from '../utils/domain'
|
|
13
14
|
import { fmtAxisDate } from '../utils/format'
|
|
14
15
|
import { smartTicks, smartTicksEvery } from '../utils/ticks'
|
|
15
16
|
import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
|
|
@@ -41,7 +42,11 @@ export type AxisConfig<T> = {
|
|
|
41
42
|
| [number, number]
|
|
42
43
|
| 'auto'
|
|
43
44
|
| ((data: readonly T[], visible: readonly ChartSeries<T>[]) => [number, number])
|
|
44
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* When 'auto': the raw upper bound is at least this, and padding is applied AFTER — mirroring
|
|
47
|
+
* `autoMinCeil`, which clamps first and pads second. A floor that padded before the clamp used
|
|
48
|
+
* to land the axis top exactly on the floor value with zero headroom.
|
|
49
|
+
*/
|
|
45
50
|
autoMaxFloor?: number
|
|
46
51
|
/** When 'auto': the lower bound is at most this. Default 0 (forced zero baseline). `Infinity`
|
|
47
52
|
* pads down from the raw data minimum instead. */
|
|
@@ -82,6 +87,14 @@ export type CartesianTooltipConfig<T> = {
|
|
|
82
87
|
follow?: boolean
|
|
83
88
|
/** Right-aligned badge in the tooltip header (e.g. a status label). */
|
|
84
89
|
label?: (d: T) => { text: string; color: string } | null
|
|
90
|
+
/**
|
|
91
|
+
* Overrides the tooltip header's date text. Default: `TooltipHeader`'s own `fmtTooltipDate`
|
|
92
|
+
* behavior, unchanged. `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
|
|
93
|
+
* LOCAL `Date` from it — a UTC ISO key then names the wrong day next to `formatX`, the tooltip
|
|
94
|
+
* badge, and every sibling chart, which all resolve locally. Receives the raw `getX` key
|
|
95
|
+
* alongside the hovered datum, so a caller can format from either.
|
|
96
|
+
*/
|
|
97
|
+
formatHeader?: (key: string, d: T) => string
|
|
85
98
|
/** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
|
|
86
99
|
* argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
|
|
87
100
|
* stays in sync with legend toggling instead of naming a series the plot no longer draws. */
|
|
@@ -149,6 +162,14 @@ export type CartesianChartProps<T> = {
|
|
|
149
162
|
series: ChartSeries<T>,
|
|
150
163
|
visible: readonly ChartSeries<T>[],
|
|
151
164
|
) => number | null
|
|
165
|
+
/**
|
|
166
|
+
* How a sibling chart's broadcast cursor key resolves against THIS chart's points. Default
|
|
167
|
+
* `'nearest'`, correct when `getX` returns an instant. Pass `'leading'` when it returns a
|
|
168
|
+
* bucket's leading edge (a weekly series keyed by its Monday): under `'nearest'` a hover landing
|
|
169
|
+
* in the back half of a bucket resolves to the FOLLOWING bucket, so the shared crosshair sits
|
|
170
|
+
* one column right of the data the reader is pointing at.
|
|
171
|
+
*/
|
|
172
|
+
cursorResolution?: CursorResolution
|
|
152
173
|
ariaLabel?: string
|
|
153
174
|
isPending?: boolean
|
|
154
175
|
/** Draw ONLY the marks. Everything around them is the primitive's job. */
|
|
@@ -196,11 +217,10 @@ export function resolveAxisDomain<T>(
|
|
|
196
217
|
|
|
197
218
|
const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
|
|
198
219
|
const ceil = cfg?.autoMinCeil ?? 0
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
const
|
|
203
|
-
const upper = Math.max(paddedMax, cfg?.autoMaxFloor ?? -Infinity)
|
|
220
|
+
// Symmetric with the lower bound: clamp the RAW value against the floor/ceiling first, then pad
|
|
221
|
+
// once, away from zero. Padding before the clamp (the old law) lands a floored axis top exactly
|
|
222
|
+
// on the floor with zero headroom — a target line pinned there sits glued to the plot edge.
|
|
223
|
+
const upper = padAutoUpper(Math.max(max, cfg?.autoMaxFloor ?? -Infinity), pad)
|
|
204
224
|
const lower = padAutoLower(Math.min(min, ceil), pad)
|
|
205
225
|
// A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
|
|
206
226
|
// which is a scale with no extent: every point maps to the same pixel and the line renders as a
|
|
@@ -241,6 +261,7 @@ export function CartesianChart<T>({
|
|
|
241
261
|
tooltip,
|
|
242
262
|
margin: marginOverride,
|
|
243
263
|
cursorValue,
|
|
264
|
+
cursorResolution,
|
|
244
265
|
ariaLabel,
|
|
245
266
|
isPending,
|
|
246
267
|
children,
|
|
@@ -274,6 +295,7 @@ export function CartesianChart<T>({
|
|
|
274
295
|
{...(tooltip !== undefined && { tooltip })}
|
|
275
296
|
{...(marginOverride !== undefined && { marginOverride })}
|
|
276
297
|
{...(cursorValue !== undefined && { cursorValue })}
|
|
298
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
277
299
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
278
300
|
plot={plot}
|
|
279
301
|
highlighted={highlighted}
|
|
@@ -311,6 +333,7 @@ function CartesianPlot<T>({
|
|
|
311
333
|
tooltip,
|
|
312
334
|
marginOverride,
|
|
313
335
|
cursorValue,
|
|
336
|
+
cursorResolution,
|
|
314
337
|
ariaLabel,
|
|
315
338
|
plot,
|
|
316
339
|
highlighted,
|
|
@@ -430,6 +453,7 @@ function CartesianPlot<T>({
|
|
|
430
453
|
getKey: getX,
|
|
431
454
|
xScale,
|
|
432
455
|
marginLeft: margin.left,
|
|
456
|
+
...(cursorResolution !== undefined && { resolution: cursorResolution }),
|
|
433
457
|
})
|
|
434
458
|
|
|
435
459
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
@@ -438,6 +462,7 @@ function CartesianPlot<T>({
|
|
|
438
462
|
const point = cursor.point
|
|
439
463
|
const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
|
|
440
464
|
const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
|
|
465
|
+
const formatHeader = tooltipCfg?.formatHeader
|
|
441
466
|
|
|
442
467
|
// Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
|
|
443
468
|
// formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
|
|
@@ -574,6 +599,9 @@ function CartesianPlot<T>({
|
|
|
574
599
|
<ChartTooltipFloat anchor={tooltipAnchor}>
|
|
575
600
|
<TooltipHeader
|
|
576
601
|
date={getX(point)}
|
|
602
|
+
{...(formatHeader !== undefined && {
|
|
603
|
+
format: (key: string) => formatHeader(key, point),
|
|
604
|
+
})}
|
|
577
605
|
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
578
606
|
/>
|
|
579
607
|
<TooltipBody>
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* to the DOM harness would only be worth it if a future assertion here needed a real measured size
|
|
14
14
|
* (a live `ResizeObserver` reading) rather than this SSR fallback rect.
|
|
15
15
|
*/
|
|
16
|
+
import { render } from '@testing-library/react'
|
|
16
17
|
import { describe, expect, test } from 'bun:test'
|
|
17
18
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
18
19
|
import { ChartFrame } from './ChartFrame'
|
|
20
|
+
import { HoverOverlay } from './HoverOverlay'
|
|
19
21
|
import type { SeriesStyle } from '../series'
|
|
20
22
|
|
|
21
23
|
const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
|
|
@@ -66,6 +68,46 @@ describe('a non-pending ChartFrame is unaffected — the legend and body both re
|
|
|
66
68
|
})
|
|
67
69
|
})
|
|
68
70
|
|
|
71
|
+
describe('ariaLabel: the label is announced WITHOUT swallowing the interactive slider', () => {
|
|
72
|
+
// The regression this guards: `role="img"` marks every descendant presentational per the ARIA
|
|
73
|
+
// spec, so a screen reader would announce the label and then never expose `HoverOverlay`'s
|
|
74
|
+
// `role="slider"` at all — the keyboard-scrubbable affordance becomes unreachable with no error
|
|
75
|
+
// anywhere. `role="group"` announces the same label while keeping descendants in the tree.
|
|
76
|
+
test('the outer container is role="group" with the label, never role="img"', () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
|
|
79
|
+
{() => <svg />}
|
|
80
|
+
</ChartFrame>,
|
|
81
|
+
)
|
|
82
|
+
const outer = container.firstElementChild as HTMLElement
|
|
83
|
+
expect(outer.getAttribute('role')).toBe('group')
|
|
84
|
+
expect(outer.getAttribute('aria-label')).toBe('Revenue over time')
|
|
85
|
+
expect(container.querySelector('[role="img"]')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('the slider stays a reachable descendant of the labeled container', () => {
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
|
|
91
|
+
{() => (
|
|
92
|
+
<svg>
|
|
93
|
+
<HoverOverlay
|
|
94
|
+
width={100}
|
|
95
|
+
height={100}
|
|
96
|
+
onMove={() => {}}
|
|
97
|
+
onLeave={() => {}}
|
|
98
|
+
onKeyDown={() => {}}
|
|
99
|
+
/>
|
|
100
|
+
</svg>
|
|
101
|
+
)}
|
|
102
|
+
</ChartFrame>,
|
|
103
|
+
)
|
|
104
|
+
const outer = container.firstElementChild as HTMLElement
|
|
105
|
+
expect(outer.getAttribute('role')).toBe('group')
|
|
106
|
+
const slider = outer.querySelector('[role="slider"]')
|
|
107
|
+
expect(slider).not.toBeNull()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
69
111
|
const twoSeries: SeriesStyle[] = [
|
|
70
112
|
{ key: 'a', label: 'Series A', color: '#000', mark: 'line' },
|
|
71
113
|
{ key: 'b', label: 'Series B', color: '#111', mark: 'bar' },
|
|
@@ -53,9 +53,16 @@ export type ChartFrameProps = {
|
|
|
53
53
|
*/
|
|
54
54
|
isPending?: boolean
|
|
55
55
|
/**
|
|
56
|
-
* Accessible text alternative for the chart, applied as `aria-label` (+ `role="
|
|
56
|
+
* Accessible text alternative for the chart, applied as `aria-label` (+ `role="group"`) on the
|
|
57
57
|
* outer container so screen readers announce something other than an unlabeled graphic. Every
|
|
58
58
|
* kind composing `ChartFrame` should accept and forward this from its own props.
|
|
59
|
+
*
|
|
60
|
+
* MUST stay `role="group"`, never `role="img"`. Per the ARIA spec, every descendant of an
|
|
61
|
+
* `role="img"` element is presentational, which erases the `HoverOverlay`'s `role="slider"` from
|
|
62
|
+
* the accessibility tree entirely — a screen reader announces the label and then the
|
|
63
|
+
* keyboard-scrubbable slider underneath it is simply unreachable, silently, with no error
|
|
64
|
+
* anywhere. `role="group"` announces the same label while keeping descendants exposed. Do not
|
|
65
|
+
* "simplify" this back to `img` — it looks like a no-op refactor and it is not.
|
|
59
66
|
*/
|
|
60
67
|
ariaLabel?: string
|
|
61
68
|
/** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
|
|
@@ -189,7 +196,7 @@ export function ChartFrame({
|
|
|
189
196
|
<div
|
|
190
197
|
ref={containerRef}
|
|
191
198
|
style={outerStyle(fill, vertical)}
|
|
192
|
-
{...(ariaLabel !== undefined && { role: '
|
|
199
|
+
{...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
|
|
193
200
|
{...(isPending && { 'aria-busy': 'true' })}
|
|
194
201
|
>
|
|
195
202
|
{legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TooltipHeader` — the `format` seam that lets a caller override `fmtTooltipDate`. Added because
|
|
3
|
+
* `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
|
|
4
|
+
* domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
8
|
+
import { TooltipHeader } from './ChartTooltip'
|
|
9
|
+
|
|
10
|
+
describe('TooltipHeader — format prop', () => {
|
|
11
|
+
test('defaults to fmtTooltipDate when omitted', () => {
|
|
12
|
+
const html = renderToStaticMarkup(<TooltipHeader date="2026-04-21" />)
|
|
13
|
+
expect(html).toContain('Tue Apr 21 2026')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a custom format overrides fmtTooltipDate entirely', () => {
|
|
17
|
+
const html = renderToStaticMarkup(
|
|
18
|
+
<TooltipHeader date="2026-04-21" format={(d) => `custom:${d}`} />,
|
|
19
|
+
)
|
|
20
|
+
expect(html).toContain('custom:2026-04-21')
|
|
21
|
+
expect(html).not.toContain('Tue Apr 21 2026')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -26,10 +26,16 @@ export function TooltipHeader({
|
|
|
26
26
|
date,
|
|
27
27
|
label,
|
|
28
28
|
labelColor,
|
|
29
|
+
format = fmtTooltipDate,
|
|
29
30
|
}: {
|
|
30
31
|
date: string
|
|
31
32
|
label?: string
|
|
32
33
|
labelColor?: string
|
|
34
|
+
/** Overrides `fmtTooltipDate` for the header text. Default: today's `fmtTooltipDate` behavior,
|
|
35
|
+
* unchanged. The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and
|
|
36
|
+
* builds a LOCAL `Date`, so a UTC ISO domain key names the wrong day next to `formatX`/the
|
|
37
|
+
* tooltip badge, which both resolve locally — a caller with a UTC key needs to supply its own. */
|
|
38
|
+
format?: (date: string) => string
|
|
33
39
|
}) {
|
|
34
40
|
return (
|
|
35
41
|
<div
|
|
@@ -50,7 +56,7 @@ export function TooltipHeader({
|
|
|
50
56
|
color: VX.ink,
|
|
51
57
|
}}
|
|
52
58
|
>
|
|
53
|
-
{
|
|
59
|
+
{format(date)}
|
|
54
60
|
</span>
|
|
55
61
|
{label !== undefined && (
|
|
56
62
|
<span
|
package/src/charts/series.ts
CHANGED
|
@@ -96,8 +96,9 @@ export type ChartSeries<T> = SeriesStyle & {
|
|
|
96
96
|
* alongside the value so a row can read fields beyond the plotted number (e.g. `97.5 kg (92.5 ×
|
|
97
97
|
* 3)`). */
|
|
98
98
|
formatValue?: (v: number, d: T) => string
|
|
99
|
-
/** PR star / status dot. Return null for no marker at that point.
|
|
100
|
-
|
|
99
|
+
/** PR star / status dot. Return null for no marker at that point. `ring` defaults true (today's
|
|
100
|
+
* punched-out stroke, unchanged); `false` omits the stroke entirely. `fillOpacity` defaults 1. */
|
|
101
|
+
getMarker?: (d: T) => { color?: string; r?: number; fillOpacity?: number; ring?: boolean } | null
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/** A single derived tooltip row, ready for `TooltipRow`. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { padAutoLower } from './domain'
|
|
2
|
+
import { padAutoLower, padAutoUpper } from './domain'
|
|
3
3
|
|
|
4
4
|
describe('padAutoLower', () => {
|
|
5
5
|
test('stays at 0 for the default zero-baseline case (never goes negative)', () => {
|
|
@@ -22,3 +22,33 @@ describe('padAutoLower', () => {
|
|
|
22
22
|
}
|
|
23
23
|
})
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
describe('padAutoUpper', () => {
|
|
27
|
+
test('stays at 0 (never pushed positive)', () => {
|
|
28
|
+
expect(padAutoUpper(0, 1.1)).toBe(0)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('pads UP above a positive candidate', () => {
|
|
32
|
+
expect(padAutoUpper(78, 1.1)).toBeCloseTo(85.8, 5)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('pads further up (toward and past zero) for a negative candidate', () => {
|
|
36
|
+
expect(padAutoUpper(-5, 1.1)).toBeCloseTo(-4.5454, 3)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('upper never lands below the candidate it was derived from', () => {
|
|
40
|
+
for (const candidate of [-10, -0.5, 0, 0.5, 10, 78]) {
|
|
41
|
+
expect(padAutoUpper(candidate, 1.1)).toBeGreaterThanOrEqual(candidate)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('the autoMaxFloor case: dataMax 3.2, pad 1.1, floor 6 -> 6.6', () => {
|
|
46
|
+
expect(padAutoUpper(Math.max(3.2, 6), 1.1)).toBeCloseTo(6.6, 5)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('is the exact mirror of padAutoLower — same magnitude, opposite direction', () => {
|
|
50
|
+
for (const candidate of [-10, -0.5, 0.5, 10, 78]) {
|
|
51
|
+
expect(padAutoUpper(candidate, 1.1)).toBeCloseTo(-padAutoLower(-candidate, 1.1), 5)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -20,3 +20,22 @@ export function padAutoLower(candidate: number, pad: number): number {
|
|
|
20
20
|
if (candidate === 0) return 0
|
|
21
21
|
return candidate > 0 ? candidate / pad : candidate * pad
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Auto y-domain upper-bound padding — the exact mirror of {@link padAutoLower}, sharing its
|
|
26
|
+
* sign-safety: pad AWAY from zero (`upper >= candidate`), never toward it. `candidate` is the
|
|
27
|
+
* caller's `Math.max(dataMax, floor)` — the `autoMaxFloor` clamp has already been applied, so
|
|
28
|
+
* padding runs AFTER the floor, not before it. Applying the floor post-pad (the pre-existing bug)
|
|
29
|
+
* lands a floored axis top exactly on the floor value with zero headroom — a target line pinned to
|
|
30
|
+
* that floor then sits glued to the plot edge instead of getting the same breathing room every
|
|
31
|
+
* other bound gets.
|
|
32
|
+
*
|
|
33
|
+
* - `candidate === 0` stays exactly 0 — never pushed positive.
|
|
34
|
+
* - `candidate > 0` multiplies by `pad`, moving the ceiling further UP (away from zero).
|
|
35
|
+
* - `candidate < 0` divides by `pad`, moving the ceiling UP toward (and past) zero — the mirror of
|
|
36
|
+
* `padAutoLower`'s `candidate > 0` branch.
|
|
37
|
+
*/
|
|
38
|
+
export function padAutoUpper(candidate: number, pad: number): number {
|
|
39
|
+
if (candidate === 0) return 0
|
|
40
|
+
return candidate > 0 ? candidate * pad : candidate / pad
|
|
41
|
+
}
|