basalt-ui 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/AGENTS.md +9 -1
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +125 -12
  4. package/README.md +45 -11
  5. package/agent/rules/basalt-app.md +26 -2
  6. package/agent/rules/basalt-charts.md +40 -5
  7. package/agent/rules/basalt-tokens.md +17 -1
  8. package/agent/skills/basalt-charts/SKILL.md +23 -8
  9. package/agent/templates/DESIGN.md.tpl +3 -1
  10. package/configs/oxlint-plugin.js +49 -8
  11. package/configs/oxlint-plugin.test.ts +88 -31
  12. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  13. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  14. package/dist/charts/hooks/useBandPlot.js +127 -0
  15. package/dist/charts/index.d.ts +4 -0
  16. package/dist/charts/index.d.ts.map +1 -1
  17. package/dist/charts/index.js +14 -0
  18. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  19. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  20. package/dist/charts/kinds/BandStrip.js +218 -0
  21. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  22. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  23. package/dist/charts/kinds/Bars.d.ts +5 -1
  24. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  25. package/dist/charts/kinds/Bars.js +2 -0
  26. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  27. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  28. package/dist/charts/kinds/MirroredBars.js +315 -0
  29. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  30. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  31. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  32. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.js +2 -0
  34. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  35. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  36. package/dist/charts/kinds/StackedArea.js +2 -0
  37. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/kinds/ZonedLine.js +2 -0
  40. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  41. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  42. package/dist/charts/primitives/CartesianChart.js +5 -2
  43. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  44. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  45. package/dist/charts/primitives/HatchPattern.js +31 -0
  46. package/dist/cli/index.d.ts +2 -0
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +47 -8
  49. package/dist/cli/shipped-versions.test.d.ts +2 -0
  50. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +32 -11
  53. package/dist/vite.d.ts +42 -3
  54. package/dist/vite.d.ts.map +1 -1
  55. package/dist/vite.js +67 -30
  56. package/llms.txt +2 -2
  57. package/package.json +1 -1
  58. package/src/charts/hooks/useBandPlot.ts +302 -0
  59. package/src/charts/index.ts +14 -0
  60. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  61. package/src/charts/kinds/BandStrip.tsx +364 -0
  62. package/src/charts/kinds/Bars.tsx +7 -1
  63. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  64. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  65. package/src/charts/kinds/MirroredBars.tsx +455 -0
  66. package/src/charts/kinds/MultiLine.tsx +7 -1
  67. package/src/charts/kinds/StackedArea.tsx +7 -1
  68. package/src/charts/kinds/ZonedLine.tsx +7 -1
  69. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  70. package/src/charts/primitives/CartesianChart.tsx +28 -3
  71. package/src/charts/primitives/HatchPattern.tsx +55 -0
  72. package/src/cli/index.ts +125 -18
  73. package/src/cli/shipped-versions.test.ts +83 -0
  74. package/src/cli/toolchain-wiring.test.ts +171 -4
  75. package/src/cli/waiver-audit.test.ts +7 -3
  76. package/src/guard/check-source.test.ts +138 -42
  77. package/src/guard/index.ts +83 -17
  78. package/src/vite.test.ts +79 -0
  79. package/src/vite.ts +141 -39
@@ -0,0 +1,364 @@
1
+ import { Group } from '@visx/group'
2
+ import { memo, useMemo } from 'react'
3
+ import type { ReactNode } from 'react'
4
+ import { VX, alpha } from '../../tokens'
5
+ import type { ChartMargin } from '../../tokens'
6
+ import type { CursorResolution } from '../cursor/resolve'
7
+ import { useBandPlot } from '../hooks/useBandPlot'
8
+ import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
9
+ import { AxisBottomDate } from '../primitives/Axes'
10
+ import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
11
+ import {
12
+ ChartTooltipFloat,
13
+ TooltipBody,
14
+ TooltipHeader,
15
+ TooltipRow,
16
+ } from '../primitives/ChartTooltip'
17
+ import { Crosshair } from '../primitives/Crosshair'
18
+ import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
19
+ import { HoverOverlay } from '../primitives/HoverOverlay'
20
+ import type { ChartLegendConfig, SeriesStyle } from '../series'
21
+ import { fmtAxisDate } from '../utils/format'
22
+
23
+ /**
24
+ * A state a strip can draw. Extends `SeriesStyle` rather than `ChartSeries` because a band has no
25
+ * numeric value to plot — `formatValue` reads the datum directly.
26
+ */
27
+ export type BandStripSeries<T> = SeriesStyle & {
28
+ /** The derived tooltip row's value for a band in this state. Omit for a state whose NAME is the
29
+ * whole reading ("Not measured"), which then renders as a label with an empty value. */
30
+ formatValue?: (d: T) => string
31
+ }
32
+
33
+ /** One drawn band — which state it is in, and the three ways a state can be qualified. */
34
+ export type BandSpan = {
35
+ /**
36
+ * Series key naming this band's state: the tie to its legend entry, its fill, and its derived
37
+ * tooltip row. A key not present in `series` draws nothing — the loud failure is deliberate,
38
+ * since the alternative is a mark no legend entry accounts for.
39
+ */
40
+ state: string
41
+ /**
42
+ * Overrides the fill derived from the state's series entry — the seam for an intensity ramp
43
+ * WITHIN one state (5% loss vs 0.5% loss are the same state at different strengths). Build it
44
+ * with `alpha(VX.*, …)`, never a raw hex.
45
+ */
46
+ fill?: string
47
+ /**
48
+ * 0..1 of this band's width that no source sample covers, drawn hatched from the band's right.
49
+ *
50
+ * This is what a fold owes the reader. A 1-of-3-measured slot whose merge reports the one
51
+ * measured member's reading paints, without this, as a fully-measured band — a reading asserted
52
+ * over two slots that reported nothing either way.
53
+ */
54
+ absentFraction?: number
55
+ /**
56
+ * A shorter, inset bar drawn over the band — for a fact that must NOT be readable off the fill
57
+ * ramp (a renegotiation, a restart, a config change). Its colour comes from a `series` entry, so
58
+ * it cannot be drawn in a hue the legend does not name.
59
+ */
60
+ marker?: {
61
+ /** Series key naming the marker's colour. Defaults to the band's own `state`. */
62
+ state?: string
63
+ /** Vertical inset from the band's top and bottom, in px. Default 6. */
64
+ inset?: number
65
+ }
66
+ }
67
+
68
+ export type BandStripProps<T> = {
69
+ data: readonly T[]
70
+ /** Stable id — identifies this chart as the cursor's source. */
71
+ chartId: string
72
+ /** Extracts the x-domain key. On a band strip this is a SLOT START, so `cursorResolution`
73
+ * defaults to `'leading'` (see below) rather than `CartesianChart`'s `'nearest'`. */
74
+ getX: (d: T) => string
75
+ /** The states this strip can draw — the single source of truth for legend entries, band fills,
76
+ * and the derived tooltip row. */
77
+ series: readonly BandStripSeries<T>[]
78
+ /** Which state each datum is in. */
79
+ getBand: (d: T) => BandSpan
80
+ /** Fixed height in px, forwarded to `ChartFrame`. The band row gets whatever the measured
81
+ * margins and the measured legend band leave. Default 240. */
82
+ height?: number
83
+ /** Fill the parent flex/grid cell's measured height instead of a fixed one. */
84
+ fill?: boolean
85
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
86
+ formatX?: (key: string) => string
87
+ /** Which keys get a tick. Default `smartTicks`. */
88
+ xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
89
+ /** Collapse adjacent data at widths where one band per datum sub-pixels. */
90
+ fold?: BandFold<T>
91
+ /**
92
+ * How a sibling's broadcast cursor key resolves against this strip's slots. Default
93
+ * `'leading'` — strict containment, "the slot that swallowed this key" — because a band IS a
94
+ * span keyed by its leading edge, and it stays correct at every fold width. `'nearest'` puts
95
+ * the crosshair one slot right for every source key in the back half of a slot.
96
+ */
97
+ cursorResolution?: CursorResolution
98
+ tooltip?: BandTooltipConfig<T> | false
99
+ legend?: ChartLegendConfig | false
100
+ /** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
101
+ * state — a hatched band is the one mark a reader cannot decode from an axis. Default
102
+ * `VX.neutral`, unnamed. */
103
+ absentState?: string
104
+ /** Per-side overrides of the measured margins — applied last. */
105
+ margin?: Partial<ChartMargin>
106
+ /** Accessible text alternative, forwarded to `ChartFrame` (+ `role="group"`) and to the
107
+ * focusable hover overlay's slider label. */
108
+ ariaLabel?: string
109
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc. */
110
+ isPending?: boolean
111
+ }
112
+
113
+ const DEFAULT_MARKER_INSET = 6
114
+
115
+ /**
116
+ * A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
117
+ *
118
+ * `CartesianChart` cannot host this. It renders `AxisLeftNumeric` unconditionally, so composing it
119
+ * would draw a numeric axis over a chart that measures nothing vertically — and its `scalePoint` x
120
+ * scale has positions but no widths, which is the whole content of a strip. This is the
121
+ * non-single-plot escape `docs/CHARTS-SPEC.md` § "The contract" describes, assembled from the same
122
+ * parts every other chart gets (`ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat`)
123
+ * via `useBandPlot`, never re-implemented.
124
+ *
125
+ * The three qualifications on {@link BandSpan} — `fill`, `absentFraction`, `marker` — are the ones
126
+ * a state strip cannot do without: an intensity ramp inside a state, the share of a folded slot
127
+ * nothing measured, and a fact that must not be read off the ramp.
128
+ */
129
+ function BandStripInner<T>(props: BandStripProps<T>) {
130
+ const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
131
+
132
+ return (
133
+ <ChartFrame
134
+ series={series}
135
+ chartId={chartId}
136
+ {...(height !== undefined && { height })}
137
+ {...(fill !== undefined && { fill })}
138
+ {...(ariaLabel !== undefined && { ariaLabel })}
139
+ {...(isPending !== undefined && { isPending })}
140
+ legend={resolveLegend(legend)}
141
+ >
142
+ {(plot) => <BandStripPlot {...props} plot={plot} />}
143
+ </ChartFrame>
144
+ )
145
+ }
146
+
147
+ type BandStripPlotProps<T> = BandStripProps<T> & {
148
+ plot: { width: number; height: number; hidden: ReadonlySet<string> }
149
+ }
150
+
151
+ /** The measured plot — split from {@link BandStripInner} so its scale/cursor hooks only run once
152
+ * `ChartFrame` has resolved a non-empty plot rect (parity with `DualPanel`). */
153
+ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
154
+ const {
155
+ data,
156
+ chartId,
157
+ getX,
158
+ series,
159
+ getBand,
160
+ formatX = fmtAxisDate,
161
+ xTickValues,
162
+ fold,
163
+ cursorResolution = 'leading',
164
+ tooltip,
165
+ absentState,
166
+ margin: marginOverride,
167
+ ariaLabel,
168
+ plot,
169
+ } = props
170
+
171
+ const { hidden } = plot
172
+ const styleByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
173
+
174
+ const band = useBandPlot<T>({
175
+ data,
176
+ chartId,
177
+ getX,
178
+ formatX,
179
+ width: plot.width,
180
+ cursorResolution,
181
+ ...(fold !== undefined && { fold }),
182
+ ...(xTickValues !== undefined && { xTickValues }),
183
+ ...(marginOverride !== undefined && { margin: marginOverride }),
184
+ ...(tooltip !== undefined && { tooltip }),
185
+ })
186
+
187
+ const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
188
+ const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
189
+ const hatchId = `${chartId}-band-absent`
190
+ const absentColor =
191
+ (absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
192
+
193
+ const rects = useMemo<ReactNode[]>(() => {
194
+ const out: ReactNode[] = []
195
+ bands.forEach((d, i) => {
196
+ const span = getBand(d)
197
+ const style = styleByKey.get(span.state)
198
+ if (style === undefined || hidden.has(span.state)) return
199
+ // Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
200
+ // 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
201
+ // straight into `width="NaN"`, a band that silently fails to paint.
202
+ const absent = clampFraction(span.absentFraction)
203
+ const measuredWidth = bandWidth * (1 - absent)
204
+ const hatchWidth = bandWidth - measuredWidth
205
+ const x = i * step
206
+ const markerKey = span.marker?.state ?? span.state
207
+ const markerColor = styleByKey.get(markerKey)?.color
208
+ const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
209
+ out.push(
210
+ <g key={getX(d)}>
211
+ {measuredWidth > 0 && (
212
+ <rect
213
+ x={x}
214
+ y={0}
215
+ width={measuredWidth}
216
+ height={stripHeight}
217
+ rx={1}
218
+ fill={span.fill ?? alpha(style.color, style.fillOpacity ?? 1)}
219
+ pointerEvents="none"
220
+ />
221
+ )}
222
+ {hatchWidth > 0 && (
223
+ <rect
224
+ x={x + measuredWidth}
225
+ y={0}
226
+ width={hatchWidth}
227
+ height={stripHeight}
228
+ rx={1}
229
+ fill={hatchFill(hatchId)}
230
+ pointerEvents="none"
231
+ />
232
+ )}
233
+ {span.marker !== undefined && markerColor !== undefined && measuredWidth > 0 && (
234
+ <rect
235
+ x={x}
236
+ y={inset}
237
+ width={measuredWidth}
238
+ height={Math.max(stripHeight - 2 * inset, 2)}
239
+ rx={1}
240
+ fill={markerColor}
241
+ pointerEvents="none"
242
+ />
243
+ )}
244
+ </g>,
245
+ )
246
+ })
247
+ return out
248
+ }, [bands, getBand, getX, styleByKey, hidden, bandWidth, step, stripHeight, hatchId])
249
+
250
+ if (plotWidth <= 0 || bands.length === 0) return null
251
+
252
+ const cfg = tooltip === false ? undefined : tooltip
253
+ const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
254
+ const row = point === null ? null : deriveBandRow(getBand(point), styleByKey, hidden, point)
255
+
256
+ return (
257
+ <>
258
+ <svg ref={band.svgRef} width={plot.width} height={plot.height}>
259
+ <defs>
260
+ <HatchPattern
261
+ id={hatchId}
262
+ color={absentColor}
263
+ opacity={0.7}
264
+ size={hatchSizeFor(bandWidth)}
265
+ />
266
+ </defs>
267
+ {/* theme-allow-file basalt/hand-rolled-plot — a strip has ONE dimension: slots over a
268
+ category axis, no numeric y at all. `CartesianChart`'s contract is a single plot rect
269
+ with one or two numeric y axes and it renders `AxisLeftNumeric` unconditionally, so it
270
+ cannot express this shape. The plot is assembled here from the SAME parts every other
271
+ chart gets — `ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through
272
+ `useBandPlot`, which `MirroredBars` composes too rather than copying this file. */}
273
+ <Group left={margin.left} top={margin.top}>
274
+ {rects}
275
+
276
+ {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={stripHeight} />}
277
+
278
+ <AxisBottomDate
279
+ top={stripHeight}
280
+ scale={scale}
281
+ tickValues={band.tickValues}
282
+ tickFormat={(v) => formatX(String(v))}
283
+ />
284
+
285
+ <HoverOverlay
286
+ width={plotWidth}
287
+ height={stripHeight}
288
+ onMove={cursor.onPointerMove}
289
+ onLeave={cursor.onPointerLeave}
290
+ onKeyDown={cursor.onKeyDown}
291
+ onBlur={cursor.onBlur}
292
+ valueMax={Math.max(bands.length - 1, 0)}
293
+ {...(point !== null && {
294
+ valueNow: bands.indexOf(point),
295
+ valueText: formatX(getX(point)),
296
+ })}
297
+ {...(ariaLabel !== undefined && { ariaLabel })}
298
+ />
299
+ </Group>
300
+ </svg>
301
+
302
+ {band.showTooltip && point !== null && (
303
+ <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
304
+ <TooltipHeader
305
+ date={getX(point)}
306
+ {...(cfg?.formatHeader !== undefined && {
307
+ format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
308
+ })}
309
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
310
+ />
311
+ <TooltipBody>
312
+ {cfg?.prependRows?.(point, { hidden })}
313
+ {row !== null && (
314
+ <TooltipRow
315
+ color={row.color}
316
+ label={row.label}
317
+ value={row.value}
318
+ shape={row.shape}
319
+ dashed={row.dashed}
320
+ />
321
+ )}
322
+ {cfg?.extraRows?.(point, { hidden })}
323
+ </TooltipBody>
324
+ </ChartTooltipFloat>
325
+ )}
326
+ </>
327
+ )
328
+ }
329
+
330
+ /** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
331
+ * end, since the alternative is a band that does not render at all. */
332
+ function clampFraction(value: number | undefined): number {
333
+ if (value === undefined || !Number.isFinite(value)) return 0
334
+ return Math.min(Math.max(value, 0), 1)
335
+ }
336
+
337
+ /**
338
+ * The strip's one derived row: the hovered band's STATE, named and formatted by its own `series`
339
+ * entry. A strip cannot show a row for a state it does not draw, and cannot name a state the
340
+ * legend does not carry — the same guarantee `deriveTooltipRows` gives a cartesian chart, over a
341
+ * shape that has no numeric value to derive from.
342
+ */
343
+ function deriveBandRow<T>(
344
+ span: BandSpan,
345
+ styleByKey: ReadonlyMap<string, BandStripSeries<T>>,
346
+ hidden: ReadonlySet<string>,
347
+ datum: T,
348
+ ): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
349
+ const style = styleByKey.get(span.state)
350
+ if (style === undefined || style.tooltip === false || hidden.has(span.state)) return null
351
+ return {
352
+ color: style.color,
353
+ label: style.label,
354
+ value: style.formatValue?.(datum) ?? '',
355
+ shape: style.mark === 'line' ? 'line' : 'bar',
356
+ dashed: style.dash === 'dashed',
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
362
+ * `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
363
+ */
364
+ export const BandStrip = memo(BandStripInner) as typeof BandStripInner
@@ -107,8 +107,12 @@ export type BarsProps<T> = {
107
107
  /** Per-bar opacity override. Receives the data point + bar key. Default 0.85. */
108
108
  barOpacity?: (d: T, key: string) => number
109
109
 
110
- /** X tick count override. Default: as many as fit. */
110
+ /** Exact number of x ticks. Default: as many as fit. Ignored when `xTickValues` is set. */
111
111
  xTicks?: number
112
+ /** Which domain keys get a tick, from the full key list and the resolved plot width. Takes
113
+ * precedence over `xTicks` — see `CartesianChartProps.xTickValues` for why a count is not
114
+ * always enough. */
115
+ xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
112
116
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
113
117
  formatX?: (key: string) => string
114
118
  /**
@@ -161,6 +165,7 @@ function BarsInner<T>(props: BarsProps<T>) {
161
165
  barLayout = 'stacked',
162
166
  barOpacity,
163
167
  xTicks,
168
+ xTickValues,
164
169
  formatX,
165
170
  cursorResolution,
166
171
  tooltip,
@@ -280,6 +285,7 @@ function BarsInner<T>(props: BarsProps<T>) {
280
285
  y={yConfig}
281
286
  {...(y2 !== undefined && { y2 })}
282
287
  {...(xTicks !== undefined && { xTicks })}
288
+ {...(xTickValues !== undefined && { xTickValues })}
283
289
  {...(formatX !== undefined && { formatX })}
284
290
  {...(cursorResolution !== undefined && { cursorResolution })}
285
291
  {...(zones !== undefined && { zones })}
@@ -6,7 +6,7 @@
6
6
  * `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
7
7
  * keyboard-focusable, so `getByRole('slider')` is unambiguous.
8
8
  */
9
- import { fireEvent, render, screen, within } from '@testing-library/react'
9
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
11
  import { renderToStaticMarkup } from 'react-dom/server'
12
12
  import { ChartCursorScope } from '../cursor/scope'
@@ -499,10 +499,16 @@ describe('DualPanel — onFollow (follower behaviour parity with CartesianChart)
499
499
  test('the follower tooltip is not aria-live; the source is', async () => {
500
500
  renderPair(true)
501
501
  fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
502
- await screen.findByText('V')
503
502
 
504
- const tooltips = screen.getAllByRole('tooltip')
505
- expect(tooltips).toHaveLength(2)
503
+ // The FOLLOWER anchors synchronously off its own svg rect; the SOURCE positions against
504
+ // `cursor.anchor`, which `useChartCursor` coalesces through `requestAnimationFrame` — one frame
505
+ // later. So waiting on the follower's 'V' is no barrier for the source tooltip. Wait for the
506
+ // PAIR, which is what's being asserted.
507
+ const tooltips = await waitFor(() => {
508
+ const found = screen.getAllByRole('tooltip')
509
+ expect(found).toHaveLength(2)
510
+ return found
511
+ })
506
512
  const sourceTooltip = tooltips.find((t) => within(t).queryByText('Shown') !== null)
507
513
  const followerTooltip = tooltips.find((t) => within(t).queryByText('V') !== null)
508
514
  expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `MirroredBars` — the properties `DualPanel` provably cannot supply: two BAR panes (no line pane),
3
+ * two INDEPENDENT domains resolved from two accessors (not one signed one), and absence hatched
4
+ * across the full band rather than sitting on one side of a baseline.
5
+ *
6
+ * Under this DOM harness there is no ResizeObserver, so `ChartFrame` falls back to its `minWidth`
7
+ * floor — `plot.width` is a fixed 200.
8
+ */
9
+ import { fireEvent, render, screen } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { ChartCursorScope } from '../cursor/scope'
12
+ import type { ChartSeries } from '../series'
13
+ import { MirroredBars } from './MirroredBars'
14
+
15
+ type Point = { key: string; down: number | null; up: number | null; absent: number }
16
+
17
+ const SERIES: ChartSeries<Point>[] = [
18
+ { key: 'down', label: 'Download', color: '#06c', mark: 'bar', getValue: (d) => d.down },
19
+ { key: 'up', label: 'Upload', color: '#999', mark: 'bar', getValue: (d) => d.up },
20
+ { key: 'absent', label: 'Not measured', color: '#555', mark: 'bar', getValue: () => null },
21
+ ]
22
+
23
+ const DATA: Point[] = [
24
+ { key: '2026-08-01', down: 800, up: 40, absent: 0 },
25
+ { key: '2026-08-02', down: 400, up: 20, absent: 0 },
26
+ { key: '2026-08-03', down: null, up: null, absent: 1 },
27
+ ]
28
+
29
+ function renderChart(props: Partial<Parameters<typeof MirroredBars<Point>>[0]> = {}) {
30
+ return render(
31
+ <ChartCursorScope>
32
+ <MirroredBars<Point>
33
+ data={DATA}
34
+ chartId="mb"
35
+ getX={(d) => d.key}
36
+ series={SERIES}
37
+ up={{ key: 'up', format: (v) => `${v}u`, ticks: 2 }}
38
+ down={{ key: 'down', format: (v) => `${v}d`, ticks: 2 }}
39
+ getAbsentFraction={(d) => d.absent}
40
+ height={200}
41
+ {...props}
42
+ />
43
+ </ChartCursorScope>,
44
+ )
45
+ }
46
+
47
+ /** Tick label text of one left axis, in paint order (up pane first, down pane second). */
48
+ function axisText(container: HTMLElement, index: number): string {
49
+ return container.querySelectorAll('.visx-axis-left')[index]?.textContent ?? ''
50
+ }
51
+
52
+ describe('MirroredBars — two panes, two independent domains', () => {
53
+ test('each pane paints its OWN axis in its OWN units — a shared scale could print only one', () => {
54
+ const { container } = renderChart({
55
+ up: { key: 'up', format: (v) => `${v}u`, max: 100, ticks: 2 },
56
+ down: { key: 'down', format: (v) => `${v}d`, max: 1000, ticks: 2 },
57
+ })
58
+ expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
59
+ // Two axes, two domains, two unit suffixes — neither axis carries the other's numbers.
60
+ expect(axisText(container, 0)).toContain('100u')
61
+ expect(axisText(container, 0)).not.toContain('d')
62
+ expect(axisText(container, 1)).toContain('1000d')
63
+ expect(axisText(container, 1)).not.toContain('u')
64
+ })
65
+
66
+ test('an auto pane resolves from its OWN accessor: 40 up beside 800 down', () => {
67
+ const { container } = renderChart()
68
+ // `40` can only come from the up accessor's own maximum; on a shared [0, 800] scale d3 would
69
+ // never place a tick there.
70
+ expect(axisText(container, 0)).toContain('40u')
71
+ expect(axisText(container, 1)).not.toContain('40d')
72
+ })
73
+
74
+ test('a pinned `max` on one pane leaves the other pane’s auto domain alone', () => {
75
+ const { container } = renderChart({ up: { key: 'up', format: (v) => `${v}u`, max: 200 } })
76
+ expect(axisText(container, 0)).toContain('200u')
77
+ // Down stayed auto — its ticks still span its own 800 maximum, not the pinned 200.
78
+ expect(axisText(container, 1)).toContain('500d')
79
+ expect(axisText(container, 1)).not.toContain('200d')
80
+ })
81
+
82
+ test('`autoMaxFloor` widens a quiet pane without touching its sibling', () => {
83
+ const withoutFloor = renderChart()
84
+ expect(axisText(withoutFloor.container, 0)).toContain('40u')
85
+ withoutFloor.unmount()
86
+
87
+ const { container } = renderChart({
88
+ up: { key: 'up', format: (v) => `${v}u`, autoMaxFloor: 500, ticks: 2 },
89
+ })
90
+ // The floor lifted the up domain well past its 40 maximum...
91
+ expect(axisText(container, 0)).toContain('400u')
92
+ expect(axisText(container, 0)).not.toContain('40u')
93
+ // ...and the down pane is unmoved.
94
+ expect(axisText(container, 1)).toContain('500d')
95
+ })
96
+ })
97
+
98
+ describe('MirroredBars — geometry around the shared baseline', () => {
99
+ test('up bars grow upward from the baseline, down bars downward, and both are proportional', () => {
100
+ const { container } = renderChart()
101
+ const rects = [...container.querySelectorAll('rect')].filter(
102
+ (r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
103
+ )
104
+ // Two measured points × two panes.
105
+ expect(rects).toHaveLength(4)
106
+ const [up1, down1, up2, down2] = rects.map((r) => ({
107
+ y: Number(r.getAttribute('y')),
108
+ h: Number(r.getAttribute('height')),
109
+ }))
110
+ // Both panes share ONE baseline: the up bar's bottom edge equals the down bar's top edge.
111
+ expect(up1!.y + up1!.h).toBeCloseTo(down1!.y, 5)
112
+ expect(up2!.y + up2!.h).toBeCloseTo(down2!.y, 5)
113
+ // Half the value, half the height — within each pane's own scale.
114
+ expect(down2!.h / down1!.h).toBeCloseTo(0.5, 5)
115
+ expect(up2!.h / up1!.h).toBeCloseTo(0.5, 5)
116
+ // ...and the two panes are NOT on one scale: 40 up and 800 down draw comparable heights.
117
+ expect(up1!.h).toBeGreaterThan(down1!.h * 0.2)
118
+ })
119
+
120
+ test('an unmeasured point hatches the FULL band, not one side of the baseline', () => {
121
+ const { container } = renderChart()
122
+ const hatched = [...container.querySelectorAll('rect')].filter(
123
+ (r) => r.getAttribute('fill') === 'url(#mb-mirrored-absent)',
124
+ )
125
+ expect(hatched).toHaveLength(1)
126
+ expect(Number(hatched[0]?.getAttribute('y'))).toBe(0)
127
+ const baselineLine = container.querySelector('line[stroke-width="1"]')
128
+ const baselineY = Number(baselineLine?.getAttribute('y1'))
129
+ expect(Number(hatched[0]?.getAttribute('height'))).toBeGreaterThan(baselineY)
130
+ })
131
+
132
+ test('a NaN value draws no bar at all rather than NaN geometry', () => {
133
+ // `deriveTooltipRows` skips only null, so a NaN reaching `scaleLinear` used to emit
134
+ // `y="NaN" height="NaN"` — a bar that silently fails to paint behind a React warning.
135
+ const { container } = renderChart({
136
+ data: [{ key: '2026-08-01', down: Number.NaN, up: Number.NaN, absent: 0 }],
137
+ })
138
+ expect(container.innerHTML).not.toContain('NaN')
139
+ const painted = [...container.querySelectorAll('rect')].filter(
140
+ (r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
141
+ )
142
+ expect(painted).toHaveLength(0)
143
+ })
144
+
145
+ test('`getBarOpacity` dims a qualified measurement without changing its geometry', () => {
146
+ const { container } = renderChart({ getBarOpacity: (d) => (d.key === '2026-08-02' ? 0.45 : 1) })
147
+ const fills = [...container.querySelectorAll('rect')]
148
+ .map((r) => r.getAttribute('fill'))
149
+ .filter((f): f is string => f?.startsWith('color-mix') === true)
150
+ expect(fills).toContain('color-mix(in srgb, #06c 45%, transparent)')
151
+ expect(fills).toContain('color-mix(in srgb, #06c 100%, transparent)')
152
+ })
153
+ })
154
+
155
+ describe('MirroredBars — legend toggle removes a pane whole', () => {
156
+ test('hiding a pane drops its bars AND its axis together', () => {
157
+ const { container } = renderChart()
158
+ expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
159
+
160
+ const toggle = container.querySelector('[data-legend-key="up"]')
161
+ fireEvent.click(toggle as Element)
162
+
163
+ expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(1)
164
+ expect(container.textContent).not.toContain('40u')
165
+ expect(axisText(container, 0)).toContain('500d')
166
+ })
167
+ })
168
+
169
+ describe('MirroredBars — tooltip rows are derived, each in its own pane’s units', () => {
170
+ test('a pane row falls back to that pane’s formatter, never the other pane’s', async () => {
171
+ renderChart({ ariaLabel: 'Carried' })
172
+ const slider = screen.getByRole('slider')
173
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
174
+
175
+ const tip = await screen.findByRole('tooltip')
176
+ expect(tip.textContent).toContain('800d')
177
+ expect(tip.textContent).toContain('40u')
178
+ // The legend-only absence entry has no value, so it never becomes a row.
179
+ expect(tip.textContent).not.toContain('Not measured')
180
+ })
181
+
182
+ test('an unmeasured point yields no pane rows at all rather than a fabricated zero', async () => {
183
+ renderChart({ ariaLabel: 'Carried' })
184
+ const slider = screen.getByRole('slider')
185
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
186
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
187
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
188
+
189
+ const tip = await screen.findByRole('tooltip')
190
+ expect(tip.textContent).not.toContain('Download')
191
+ expect(tip.textContent).not.toContain('Upload')
192
+ })
193
+ })