basalt-ui 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +74 -13
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- 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 +2 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +30 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +10 -3
- package/dist/charts/kinds/Bars.test.d.ts +2 -0
- package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
- package/dist/charts/kinds/DualPanel.d.ts +30 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +82 -12
- package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
- 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 +45 -33
- package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
- 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 +5 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +45 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +29 -12
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
- 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/ChartLegend.d.ts +3 -0
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +4 -0
- 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/primitives/XZoneRects.d.ts +22 -8
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
- package/dist/charts/primitives/XZoneRects.js +39 -2
- package/dist/charts/series.d.ts +16 -3
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -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 +2 -0
- package/src/charts/kinds/Bars.test.tsx +183 -0
- package/src/charts/kinds/Bars.tsx +41 -12
- package/src/charts/kinds/DualPanel.test.tsx +441 -0
- package/src/charts/kinds/DualPanel.tsx +127 -11
- package/src/charts/kinds/MultiLine.test.tsx +218 -0
- package/src/charts/kinds/MultiLine.tsx +64 -36
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +147 -0
- package/src/charts/kinds/ZonedLine.tsx +14 -0
- package/src/charts/primitives/CartesianChart.test.tsx +269 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -19
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/primitives/XZoneRects.test.tsx +60 -0
- package/src/charts/primitives/XZoneRects.tsx +75 -10
- package/src/charts/series.test.ts +51 -0
- package/src/charts/series.ts +18 -6
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
- package/src/charts/primitives/CartesianChart.test.ts +0 -99
|
@@ -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. */
|
|
@@ -53,6 +58,23 @@ export type AxisConfig<T> = {
|
|
|
53
58
|
format?: (v: number) => string
|
|
54
59
|
/** Horizontal grid rules. Default: on for the left axis, off for the right. */
|
|
55
60
|
grid?: boolean
|
|
61
|
+
/**
|
|
62
|
+
* Round the scale's domain outward to nice tick values (d3's `scale.nice()`). Threaded to BOTH
|
|
63
|
+
* `probeAxisLabels` (measurement) and the real `scaleLinear` (rendering) — they must agree, or
|
|
64
|
+
* the measured margin is computed from ticks the axis never paints.
|
|
65
|
+
*
|
|
66
|
+
* Default `false`, deliberately: flipping the default would move the domain of every
|
|
67
|
+
* already-migrated chart. Opt in per axis.
|
|
68
|
+
*/
|
|
69
|
+
nice?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Handed to `prependRows`/`extraRows` alongside the hovered datum — the same `visible`/`hidden`
|
|
73
|
+
* the plot itself draws from, so a hand-authored row can't structurally desync from legend
|
|
74
|
+
* toggling. */
|
|
75
|
+
export type CartesianTooltipRowContext<T> = {
|
|
76
|
+
visible: readonly ChartSeries<T>[]
|
|
77
|
+
hidden: ReadonlySet<string>
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
export type CartesianTooltipConfig<T> = {
|
|
@@ -65,10 +87,20 @@ export type CartesianTooltipConfig<T> = {
|
|
|
65
87
|
follow?: boolean
|
|
66
88
|
/** Right-aligned badge in the tooltip header (e.g. a status label). */
|
|
67
89
|
label?: (d: T) => { text: string; color: string } | null
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
98
|
+
/** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
|
|
99
|
+
* argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
|
|
100
|
+
* stays in sync with legend toggling instead of naming a series the plot no longer draws. */
|
|
101
|
+
prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
102
|
+
/** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
|
|
103
|
+
extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
/** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
|
|
@@ -130,6 +162,14 @@ export type CartesianChartProps<T> = {
|
|
|
130
162
|
series: ChartSeries<T>,
|
|
131
163
|
visible: readonly ChartSeries<T>[],
|
|
132
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
|
|
133
173
|
ariaLabel?: string
|
|
134
174
|
isPending?: boolean
|
|
135
175
|
/** Draw ONLY the marks. Everything around them is the primitive's job. */
|
|
@@ -177,11 +217,10 @@ export function resolveAxisDomain<T>(
|
|
|
177
217
|
|
|
178
218
|
const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
|
|
179
219
|
const ceil = cfg?.autoMinCeil ?? 0
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
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)
|
|
185
224
|
const lower = padAutoLower(Math.min(min, ceil), pad)
|
|
186
225
|
// A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
|
|
187
226
|
// which is a scale with no extent: every point maps to the same pixel and the line renders as a
|
|
@@ -222,6 +261,7 @@ export function CartesianChart<T>({
|
|
|
222
261
|
tooltip,
|
|
223
262
|
margin: marginOverride,
|
|
224
263
|
cursorValue,
|
|
264
|
+
cursorResolution,
|
|
225
265
|
ariaLabel,
|
|
226
266
|
isPending,
|
|
227
267
|
children,
|
|
@@ -255,6 +295,7 @@ export function CartesianChart<T>({
|
|
|
255
295
|
{...(tooltip !== undefined && { tooltip })}
|
|
256
296
|
{...(marginOverride !== undefined && { marginOverride })}
|
|
257
297
|
{...(cursorValue !== undefined && { cursorValue })}
|
|
298
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
258
299
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
259
300
|
plot={plot}
|
|
260
301
|
highlighted={highlighted}
|
|
@@ -292,6 +333,7 @@ function CartesianPlot<T>({
|
|
|
292
333
|
tooltip,
|
|
293
334
|
marginOverride,
|
|
294
335
|
cursorValue,
|
|
336
|
+
cursorResolution,
|
|
295
337
|
ariaLabel,
|
|
296
338
|
plot,
|
|
297
339
|
highlighted,
|
|
@@ -339,9 +381,10 @@ function CartesianPlot<T>({
|
|
|
339
381
|
probeAxisLabels({
|
|
340
382
|
domain: leftDomain,
|
|
341
383
|
ticks: leftTicks,
|
|
384
|
+
nice: y?.nice ?? false,
|
|
342
385
|
...(y?.format !== undefined && { format: y.format }),
|
|
343
386
|
}),
|
|
344
|
-
[leftDomain, leftTicks, y?.format],
|
|
387
|
+
[leftDomain, leftTicks, y?.format, y?.nice],
|
|
345
388
|
)
|
|
346
389
|
|
|
347
390
|
const { labels: rightLabels, format: rightFormat } = useMemo(
|
|
@@ -351,9 +394,10 @@ function CartesianPlot<T>({
|
|
|
351
394
|
: probeAxisLabels({
|
|
352
395
|
domain: rightDomain,
|
|
353
396
|
ticks: rightTicks,
|
|
397
|
+
nice: y2?.nice ?? false,
|
|
354
398
|
...(y2?.format !== undefined && { format: y2.format }),
|
|
355
399
|
}),
|
|
356
|
-
[rightDomain, rightTicks, y2?.format],
|
|
400
|
+
[rightDomain, rightTicks, y2?.format, y2?.nice],
|
|
357
401
|
)
|
|
358
402
|
|
|
359
403
|
const xLabels = useMemo(() => keys.map(formatX), [keys, formatX])
|
|
@@ -378,13 +422,24 @@ function CartesianPlot<T>({
|
|
|
378
422
|
[keys, xMax],
|
|
379
423
|
)
|
|
380
424
|
const yScale = useMemo(
|
|
381
|
-
() =>
|
|
382
|
-
|
|
425
|
+
() =>
|
|
426
|
+
scaleLinear<number>({
|
|
427
|
+
domain: leftDomain,
|
|
428
|
+
range: [yMax, 0],
|
|
429
|
+
...(y?.nice === true && { nice: true }),
|
|
430
|
+
}),
|
|
431
|
+
[leftDomain, yMax, y?.nice],
|
|
383
432
|
)
|
|
384
433
|
const y2Scale = useMemo(
|
|
385
434
|
() =>
|
|
386
|
-
rightDomain === null
|
|
387
|
-
|
|
435
|
+
rightDomain === null
|
|
436
|
+
? null
|
|
437
|
+
: scaleLinear<number>({
|
|
438
|
+
domain: rightDomain,
|
|
439
|
+
range: [yMax, 0],
|
|
440
|
+
...(y2?.nice === true && { nice: true }),
|
|
441
|
+
}),
|
|
442
|
+
[rightDomain, yMax, y2?.nice],
|
|
388
443
|
)
|
|
389
444
|
|
|
390
445
|
const tickValues = useMemo(
|
|
@@ -398,6 +453,7 @@ function CartesianPlot<T>({
|
|
|
398
453
|
getKey: getX,
|
|
399
454
|
xScale,
|
|
400
455
|
marginLeft: margin.left,
|
|
456
|
+
...(cursorResolution !== undefined && { resolution: cursorResolution }),
|
|
401
457
|
})
|
|
402
458
|
|
|
403
459
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
@@ -406,6 +462,7 @@ function CartesianPlot<T>({
|
|
|
406
462
|
const point = cursor.point
|
|
407
463
|
const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
|
|
408
464
|
const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
|
|
465
|
+
const formatHeader = tooltipCfg?.formatHeader
|
|
409
466
|
|
|
410
467
|
// Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
|
|
411
468
|
// formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
|
|
@@ -542,10 +599,13 @@ function CartesianPlot<T>({
|
|
|
542
599
|
<ChartTooltipFloat anchor={tooltipAnchor}>
|
|
543
600
|
<TooltipHeader
|
|
544
601
|
date={getX(point)}
|
|
602
|
+
{...(formatHeader !== undefined && {
|
|
603
|
+
format: (key: string) => formatHeader(key, point),
|
|
604
|
+
})}
|
|
545
605
|
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
546
606
|
/>
|
|
547
607
|
<TooltipBody>
|
|
548
|
-
{tooltipCfg?.prependRows?.(point)}
|
|
608
|
+
{tooltipCfg?.prependRows?.(point, { visible, hidden })}
|
|
549
609
|
{rows.map((row) => (
|
|
550
610
|
<TooltipRow
|
|
551
611
|
key={row.key}
|
|
@@ -557,7 +617,7 @@ function CartesianPlot<T>({
|
|
|
557
617
|
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
558
618
|
/>
|
|
559
619
|
))}
|
|
560
|
-
{tooltipCfg?.extraRows?.(point)}
|
|
620
|
+
{tooltipCfg?.extraRows?.(point, { visible, hidden })}
|
|
561
621
|
</TooltipBody>
|
|
562
622
|
</ChartTooltipFloat>
|
|
563
623
|
)}
|
|
@@ -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}
|
|
@@ -46,3 +46,33 @@ describe('ChartLegend note', () => {
|
|
|
46
46
|
expect(markup).toContain('est.')
|
|
47
47
|
})
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('ChartLegend strokeOpacity — the swatch cannot lie about a dimmed line', () => {
|
|
51
|
+
test('a line swatch honors a fractional strokeOpacity', () => {
|
|
52
|
+
const markup = render([{ ...BASE, shape: 'line', strokeOpacity: 0.4 }])
|
|
53
|
+
expect(markup).toContain('stroke-opacity="0.4"')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('defaults to full opacity when unset', () => {
|
|
57
|
+
const markup = render([{ ...BASE, shape: 'line' }])
|
|
58
|
+
expect(markup).toContain('stroke-opacity="1"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a folded MA-companion child swatch honors it too', () => {
|
|
62
|
+
const markup = render([
|
|
63
|
+
{
|
|
64
|
+
...BASE,
|
|
65
|
+
children: [
|
|
66
|
+
{
|
|
67
|
+
key: 'ma',
|
|
68
|
+
label: '7d MA',
|
|
69
|
+
color: 'var(--vx-fill-2)',
|
|
70
|
+
shape: 'line',
|
|
71
|
+
strokeOpacity: 0.4,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
])
|
|
76
|
+
expect(markup).toContain('stroke-opacity="0.4"')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -13,6 +13,9 @@ export type LegendEntry = {
|
|
|
13
13
|
dashed?: boolean
|
|
14
14
|
/** bar swatch opacity — honored instead of a hardcoded value, so it cannot lie about the fill. */
|
|
15
15
|
fillOpacity?: number
|
|
16
|
+
/** line/splitLine swatch stroke opacity — parity with `fillOpacity` above, so a dimmed
|
|
17
|
+
* companion line's swatch cannot lie about its own opacity either. */
|
|
18
|
+
strokeOpacity?: number
|
|
16
19
|
/** Drives `groups` rendering (series → hairline divider → overlay → reference). */
|
|
17
20
|
role?: SeriesRole
|
|
18
21
|
/** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
|
|
@@ -113,6 +116,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
113
116
|
y2={7}
|
|
114
117
|
stroke={item.color}
|
|
115
118
|
strokeWidth={item.strokeWidth ?? 2.5}
|
|
119
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
116
120
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
117
121
|
/>
|
|
118
122
|
<line
|
|
@@ -122,6 +126,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
122
126
|
y2={7}
|
|
123
127
|
stroke={item.secondColor}
|
|
124
128
|
strokeWidth={item.strokeWidth ?? 2.5}
|
|
129
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
125
130
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
126
131
|
/>
|
|
127
132
|
</svg>
|
|
@@ -173,6 +178,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
173
178
|
y2={3}
|
|
174
179
|
stroke={item.color}
|
|
175
180
|
strokeWidth={item.strokeWidth ?? 3}
|
|
181
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
176
182
|
strokeLinecap="round"
|
|
177
183
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
178
184
|
/>
|
|
@@ -222,6 +228,7 @@ function LegendChildSwatch({ item }: { item: LegendEntry }) {
|
|
|
222
228
|
y2={5}
|
|
223
229
|
stroke={item.color}
|
|
224
230
|
strokeWidth={item.strokeWidth ?? 2}
|
|
231
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
225
232
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
226
233
|
/>
|
|
227
234
|
</svg>
|
|
@@ -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
|
|
@@ -60,4 +60,64 @@ describe('XZoneRects', () => {
|
|
|
60
60
|
test('an empty zones array renders no rects', () => {
|
|
61
61
|
expect(render([])).not.toContain('<rect')
|
|
62
62
|
})
|
|
63
|
+
|
|
64
|
+
// Mirrors XZoneRects' own bound resolution exactly (same clamp + sign math), so expectations
|
|
65
|
+
// never drift from the implementation's floating-point arithmetic.
|
|
66
|
+
function edgeBound(key: string, sign: 1 | -1): number {
|
|
67
|
+
const step = xScale.step()
|
|
68
|
+
const [rangeStart, rangeEnd] = xScale.range() as [number, number]
|
|
69
|
+
const rangeMin = Math.min(rangeStart, rangeEnd)
|
|
70
|
+
const rangeMax = Math.max(rangeStart, rangeEnd)
|
|
71
|
+
const center = xScale(key) as number
|
|
72
|
+
return Math.min(Math.max(center + sign * (step / 2), rangeMin), rangeMax)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('align: "edge" widens a two-key band by exactly one step over center mode', () => {
|
|
76
|
+
const step = xScale.step()
|
|
77
|
+
const centerFrom = xScale('b') as number
|
|
78
|
+
const centerTo = xScale('c') as number
|
|
79
|
+
const centerMarkup = render([{ from: 'b', to: 'c', fill: 'red' }])
|
|
80
|
+
const edgeMarkup = render([{ from: 'b', to: 'c', fill: 'red', align: 'edge' }])
|
|
81
|
+
expect(centerMarkup).toContain(`width="${centerTo - centerFrom}"`)
|
|
82
|
+
const edgeFrom = edgeBound('b', -1)
|
|
83
|
+
const edgeTo = edgeBound('c', 1)
|
|
84
|
+
expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
|
|
85
|
+
expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
|
|
86
|
+
expect(edgeTo - edgeFrom).toBeCloseTo(centerTo - centerFrom + step, 10)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('align: "edge" with from === to renders exactly one step wide, where center renders nothing', () => {
|
|
90
|
+
const step = xScale.step()
|
|
91
|
+
const centerMarkup = render([{ from: 'b', to: 'b', fill: 'red' }])
|
|
92
|
+
const edgeMarkup = render([{ from: 'b', to: 'b', fill: 'red', align: 'edge' }])
|
|
93
|
+
expect(centerMarkup).not.toContain('<rect')
|
|
94
|
+
const edgeFrom = edgeBound('b', -1)
|
|
95
|
+
const edgeTo = edgeBound('b', 1)
|
|
96
|
+
expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
|
|
97
|
+
expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
|
|
98
|
+
expect(edgeTo - edgeFrom).toBeCloseTo(step, 10)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an unknown key is skipped in both align modes', () => {
|
|
102
|
+
const centerMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
|
|
103
|
+
const edgeMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red', align: 'edge' }])
|
|
104
|
+
expect(centerMarkup).not.toContain('<rect')
|
|
105
|
+
expect(edgeMarkup).not.toContain('<rect')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('align: "edge" clamps the widened left edge at the first sample into the plot range', () => {
|
|
109
|
+
const [rangeStart] = xScale.range() as [number, number]
|
|
110
|
+
const markup = render([{ from: 'a', to: 'a', fill: 'red', align: 'edge' }])
|
|
111
|
+
const edgeTo = edgeBound('a', 1)
|
|
112
|
+
expect(markup).toContain(`x="${rangeStart}"`)
|
|
113
|
+
expect(markup).toContain(`width="${edgeTo - rangeStart}"`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('align: "edge" clamps the widened right edge at the last sample into the plot range', () => {
|
|
117
|
+
const [, rangeEnd] = xScale.range() as [number, number]
|
|
118
|
+
const markup = render([{ from: 'd', to: 'd', fill: 'red', align: 'edge' }])
|
|
119
|
+
const edgeFrom = edgeBound('d', -1)
|
|
120
|
+
expect(markup).toContain(`x="${edgeFrom}"`)
|
|
121
|
+
expect(markup).toContain(`width="${rangeEnd - edgeFrom}"`)
|
|
122
|
+
})
|
|
63
123
|
})
|
|
@@ -13,6 +13,50 @@ export type XZoneSpec = {
|
|
|
13
13
|
/** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
|
|
14
14
|
to?: string
|
|
15
15
|
fill: string
|
|
16
|
+
/**
|
|
17
|
+
* `'center'` (default, unchanged behaviour) resolves a present bound to `xScale(key)` — the
|
|
18
|
+
* point's own center, so a band reads "from this sample to that sample" rather than
|
|
19
|
+
* edge-to-edge. `'edge'` resolves a present `from` to `xScale(from) - step/2` and a present `to`
|
|
20
|
+
* to `xScale(to) + step/2` (`step` read from `xScale.step()`), so the band covers both terminal
|
|
21
|
+
* slots in full — a two-key band widens by exactly one step, and `from === to` renders one step
|
|
22
|
+
* wide instead of being skipped as degenerate. An omitted `from`/`to` still resolves to the plot
|
|
23
|
+
* edge (`xScale.range()[0]`/`[1]`) in both modes, unshifted — omission means "the plot's own
|
|
24
|
+
* edge", not "the first/last sample's edge". A resolved `'edge'` bound is clamped into the plot
|
|
25
|
+
* range so a band anchored at the first or last sample cannot paint outside the plot area.
|
|
26
|
+
*/
|
|
27
|
+
align?: 'center' | 'edge'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clamp(value: number, min: number, max: number): number {
|
|
31
|
+
return Math.min(Math.max(value, min), max)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves one zone bound (`from` or `to`) to a pixel x. `edgeSign` is `-1` for `from` (subtract
|
|
36
|
+
* half a step) and `+1` for `to` (add half a step) — only consulted in `'edge'` mode.
|
|
37
|
+
*/
|
|
38
|
+
function resolveBound({
|
|
39
|
+
key,
|
|
40
|
+
omittedEdge,
|
|
41
|
+
xScale,
|
|
42
|
+
align,
|
|
43
|
+
edgeSign,
|
|
44
|
+
rangeMin,
|
|
45
|
+
rangeMax,
|
|
46
|
+
}: {
|
|
47
|
+
key: string | undefined
|
|
48
|
+
omittedEdge: number
|
|
49
|
+
xScale: PointScale
|
|
50
|
+
align: 'center' | 'edge'
|
|
51
|
+
edgeSign: -1 | 1
|
|
52
|
+
rangeMin: number
|
|
53
|
+
rangeMax: number
|
|
54
|
+
}): number | undefined {
|
|
55
|
+
if (key === undefined) return omittedEdge
|
|
56
|
+
const center = xScale(key)
|
|
57
|
+
if (center === undefined) return undefined
|
|
58
|
+
if (align === 'center') return center
|
|
59
|
+
return clamp(center + edgeSign * (xScale.step() / 2), rangeMin, rangeMax)
|
|
16
60
|
}
|
|
17
61
|
|
|
18
62
|
/**
|
|
@@ -20,18 +64,20 @@ export type XZoneSpec = {
|
|
|
20
64
|
* <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
|
|
21
65
|
* `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
|
|
22
66
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
67
|
+
* Bound resolution depends on `XZoneSpec.align` (see its JSDoc for the full `'center'`/`'edge'`
|
|
68
|
+
* contract) — default `'center'` resolves a present key to the point's own center; `'edge'`
|
|
69
|
+
* widens by half a step at each present bound so the band covers the terminal slots in full.
|
|
26
70
|
*
|
|
27
71
|
* A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
|
|
28
|
-
* it is NEVER clamped to a plot edge
|
|
29
|
-
* chart for a typo'd or stale key, which is the exact class of
|
|
30
|
-
* draw is two different states" doctrine rejects: an unknown key
|
|
31
|
-
* as "the whole plot".
|
|
72
|
+
* it is NEVER clamped to a plot edge, in either mode. Clamping an unknown key would silently
|
|
73
|
+
* paint a band across the whole chart for a typo'd or stale key, which is the exact class of
|
|
74
|
+
* quiet lie the repo's "nothing to draw is two different states" doctrine rejects: an unknown key
|
|
75
|
+
* must read as "not drawn", not as "the whole plot".
|
|
32
76
|
*
|
|
33
77
|
* A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
|
|
34
|
-
* also skipped — no negative-width rect.
|
|
78
|
+
* also skipped — no negative-width rect. In `'center'` mode `from === to` is degenerate (skipped,
|
|
79
|
+
* today's behaviour); in `'edge'` mode it is NOT — the half-step widening on each side always
|
|
80
|
+
* produces a positive-width, one-step-wide rect.
|
|
35
81
|
*/
|
|
36
82
|
export function XZoneRects({
|
|
37
83
|
zones,
|
|
@@ -47,8 +93,27 @@ export function XZoneRects({
|
|
|
47
93
|
<>
|
|
48
94
|
{zones.map((z, i) => {
|
|
49
95
|
const [rangeStart, rangeEnd] = xScale.range() as [number, number]
|
|
50
|
-
const
|
|
51
|
-
const
|
|
96
|
+
const rangeMin = Math.min(rangeStart, rangeEnd)
|
|
97
|
+
const rangeMax = Math.max(rangeStart, rangeEnd)
|
|
98
|
+
const align = z.align ?? 'center'
|
|
99
|
+
const xFrom = resolveBound({
|
|
100
|
+
key: z.from,
|
|
101
|
+
omittedEdge: rangeStart,
|
|
102
|
+
xScale,
|
|
103
|
+
align,
|
|
104
|
+
edgeSign: -1,
|
|
105
|
+
rangeMin,
|
|
106
|
+
rangeMax,
|
|
107
|
+
})
|
|
108
|
+
const xTo = resolveBound({
|
|
109
|
+
key: z.to,
|
|
110
|
+
omittedEdge: rangeEnd,
|
|
111
|
+
xScale,
|
|
112
|
+
align,
|
|
113
|
+
edgeSign: 1,
|
|
114
|
+
rangeMin,
|
|
115
|
+
rangeMax,
|
|
116
|
+
})
|
|
52
117
|
if (xFrom === undefined || xTo === undefined) return null
|
|
53
118
|
if (xTo <= xFrom) return null
|
|
54
119
|
return (
|
|
@@ -58,4 +58,55 @@ describe('deriveTooltipRows', () => {
|
|
|
58
58
|
)
|
|
59
59
|
expect(rows[0]?.value).toBe('42%')
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
test('formatValue receives the hovered datum alongside the value', () => {
|
|
63
|
+
type Set = { v: number; reps: number }
|
|
64
|
+
const rows = deriveTooltipRows(
|
|
65
|
+
[
|
|
66
|
+
{
|
|
67
|
+
key: 'set',
|
|
68
|
+
label: 'Set',
|
|
69
|
+
color: '#000',
|
|
70
|
+
mark: 'bar' as const,
|
|
71
|
+
getValue: (d: Set) => d.v,
|
|
72
|
+
formatValue: (v: number, d: Set) => `${v} kg (${d.reps} reps)`,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
{ v: 97.5, reps: 3 },
|
|
76
|
+
fmt,
|
|
77
|
+
)
|
|
78
|
+
expect(rows[0]?.value).toBe('97.5 kg (3 reps)')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the fallback formatter (single-arg) still works once formatValue is 2-arg typed', () => {
|
|
82
|
+
const rows = deriveTooltipRows([{ ...base, key: 'a', label: 'A' }], { v: 5 }, fmt)
|
|
83
|
+
expect(rows[0]?.value).toBe('5')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('strokeOpacity — a mark property, not a tooltip property', () => {
|
|
88
|
+
test('reaches the derived legend entry', () => {
|
|
89
|
+
const [entry] = deriveLegend([{ ...baseSeries, strokeOpacity: 0.4 }])
|
|
90
|
+
expect(entry?.strokeOpacity).toBe(0.4)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('is absent from the legend entry when unset', () => {
|
|
94
|
+
const [entry] = deriveLegend([baseSeries])
|
|
95
|
+
expect(entry).not.toHaveProperty('strokeOpacity')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('never reaches TooltipRowData — the row is a 12px value chip, not a dimmed mark', () => {
|
|
99
|
+
const rows = deriveTooltipRows(
|
|
100
|
+
[
|
|
101
|
+
{
|
|
102
|
+
...baseSeries,
|
|
103
|
+
strokeOpacity: 0.4,
|
|
104
|
+
getValue: () => 10,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
{},
|
|
108
|
+
(v) => String(v),
|
|
109
|
+
)
|
|
110
|
+
expect(rows[0]).not.toHaveProperty('strokeOpacity')
|
|
111
|
+
})
|
|
61
112
|
})
|