basalt-ui 1.22.0 → 1.23.1

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 (85) hide show
  1. package/AGENTS.md +29 -12
  2. package/CHANGELOG.md +28 -0
  3. package/MIGRATING.md +207 -12
  4. package/README.md +74 -21
  5. package/agent/rules/basalt-app.md +33 -3
  6. package/agent/rules/basalt-charts.md +78 -7
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-mantine.md +4 -4
  9. package/agent/rules/basalt-tokens.md +35 -6
  10. package/agent/skills/basalt-app/SKILL.md +25 -12
  11. package/agent/skills/basalt-charts/SKILL.md +27 -10
  12. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  13. package/agent/templates/DESIGN.md.tpl +3 -1
  14. package/configs/oxlint-plugin.js +49 -8
  15. package/configs/oxlint-plugin.test.ts +88 -31
  16. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  17. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  18. package/dist/charts/hooks/useBandPlot.js +127 -0
  19. package/dist/charts/index.d.ts +4 -0
  20. package/dist/charts/index.d.ts.map +1 -1
  21. package/dist/charts/index.js +14 -0
  22. package/dist/charts/kinds/BandStrip.d.ts +127 -0
  23. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  24. package/dist/charts/kinds/BandStrip.js +271 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  26. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/Bars.d.ts +5 -1
  28. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  29. package/dist/charts/kinds/Bars.js +2 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts +95 -0
  31. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  32. package/dist/charts/kinds/MirroredBars.js +328 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  34. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  35. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  36. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  37. package/dist/charts/kinds/MultiLine.js +2 -0
  38. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  39. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  40. package/dist/charts/kinds/StackedArea.js +2 -0
  41. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  42. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  43. package/dist/charts/kinds/ZonedLine.js +2 -0
  44. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  45. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  46. package/dist/charts/primitives/CartesianChart.js +5 -2
  47. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  48. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  49. package/dist/charts/primitives/HatchPattern.js +31 -0
  50. package/dist/cli/index.d.ts +2 -0
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +228 -31
  53. package/dist/cli/shipped-versions.test.d.ts +2 -0
  54. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +60 -13
  57. package/dist/vite.d.ts +42 -3
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +67 -30
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +282 -0
  65. package/src/charts/kinds/BandStrip.tsx +457 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +232 -0
  69. package/src/charts/kinds/MirroredBars.tsx +491 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/help.test.ts +74 -1
  77. package/src/cli/index.ts +436 -52
  78. package/src/cli/shipped-versions.test.ts +83 -0
  79. package/src/cli/tokens-css.test.ts +62 -0
  80. package/src/cli/toolchain-wiring.test.ts +171 -4
  81. package/src/cli/waiver-audit.test.ts +7 -3
  82. package/src/guard/check-source.test.ts +211 -42
  83. package/src/guard/index.ts +149 -23
  84. package/src/vite.test.ts +79 -0
  85. package/src/vite.ts +141 -39
@@ -0,0 +1,457 @@
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
+ /**
29
+ * The derived tooltip row's value for a band in this state.
30
+ *
31
+ * Return `null` for a datum in this state whose reading is ABSENT — the row renders
32
+ * {@link NO_READING} instead, which is the escape `ChartSeries.getValue` has and this shape did
33
+ * not: with no value accessor, an absent reading had to come back as `''` and render as a label
34
+ * with an empty value, indistinguishable from a state whose name is the whole reading.
35
+ *
36
+ * Omit the function entirely for a state whose NAME *is* the whole reading ("Not measured").
37
+ */
38
+ formatValue?: (d: T) => string | null
39
+ }
40
+
41
+ /** One drawn band — which state it is in, and the three ways a state can be qualified. */
42
+ export type BandSpan = {
43
+ /**
44
+ * Series key naming this band's state: the tie to its legend entry, its fill, and its derived
45
+ * tooltip row.
46
+ *
47
+ * A key not present in `series` is a bug TypeScript cannot catch (this is a `string`), and it
48
+ * used to draw NOTHING — which on a strip whose vocabulary is measured/not-measured is
49
+ * indistinguishable from a real coverage gap, so a typo silently asserted "not measured". It now
50
+ * throws in development and draws an explicit unknown band in production; see
51
+ * {@link unknownStateError} for why the two differ.
52
+ */
53
+ state: string
54
+ /**
55
+ * Overrides the fill derived from the state's series entry — the seam for an intensity ramp
56
+ * WITHIN one state (5% loss vs 0.5% loss are the same state at different strengths). Build it
57
+ * with `alpha(VX.*, …)`, never a raw hex.
58
+ */
59
+ fill?: string
60
+ /**
61
+ * 0..1 of this band's width that no source sample covers, drawn hatched from the band's right.
62
+ *
63
+ * This is what a fold owes the reader. A 1-of-3-measured slot whose merge reports the one
64
+ * measured member's reading paints, without this, as a fully-measured band — a reading asserted
65
+ * over two slots that reported nothing either way.
66
+ */
67
+ absentFraction?: number
68
+ /**
69
+ * A shorter, inset bar drawn over the band — for a fact that must NOT be readable off the fill
70
+ * ramp (a renegotiation, a restart, a config change). Its colour comes from a `series` entry, so
71
+ * it cannot be drawn in a hue the legend does not name.
72
+ */
73
+ marker?: {
74
+ /** Series key naming the marker's colour. Defaults to the band's own `state`. */
75
+ state?: string
76
+ /** Vertical inset from the band's top and bottom, in px. Default 6. */
77
+ inset?: number
78
+ }
79
+ }
80
+
81
+ export type BandStripProps<T> = {
82
+ data: readonly T[]
83
+ /** Stable id — identifies this chart as the cursor's source. */
84
+ chartId: string
85
+ /** Extracts the x-domain key. On a band strip this is a SLOT START, so `cursorResolution`
86
+ * defaults to `'leading'` (see below) rather than `CartesianChart`'s `'nearest'`. */
87
+ getX: (d: T) => string
88
+ /** The states this strip can draw — the single source of truth for legend entries, band fills,
89
+ * and the derived tooltip row. */
90
+ series: readonly BandStripSeries<T>[]
91
+ /** Which state each datum is in. */
92
+ getBand: (d: T) => BandSpan
93
+ /** Fixed height in px, forwarded to `ChartFrame`. The band row gets whatever the measured
94
+ * margins and the measured legend band leave. Default 240. */
95
+ height?: number
96
+ /** Fill the parent flex/grid cell's measured height instead of a fixed one. */
97
+ fill?: boolean
98
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
99
+ formatX?: (key: string) => string
100
+ /** Which keys get a tick. Default `smartTicks`. */
101
+ xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
102
+ /** Collapse adjacent data at widths where one band per datum sub-pixels. */
103
+ fold?: BandFold<T>
104
+ /**
105
+ * How a sibling's broadcast cursor key resolves against this strip's slots. Default
106
+ * `'leading'` — strict containment, "the slot that swallowed this key" — because a band IS a
107
+ * span keyed by its leading edge, and it stays correct at every fold width. `'nearest'` puts
108
+ * the crosshair one slot right for every source key in the back half of a slot.
109
+ */
110
+ cursorResolution?: CursorResolution
111
+ tooltip?: BandTooltipConfig<T> | false
112
+ legend?: ChartLegendConfig | false
113
+ /** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
114
+ * state — a hatched band is the one mark a reader cannot decode from an axis. Default
115
+ * `VX.neutral`, unnamed. A key naming no `series` entry throws (a PROP, so a typo can only be a
116
+ * wiring error) rather than falling back to the unnamed default. */
117
+ absentState?: string
118
+ /** Per-side overrides of the measured margins — applied last. */
119
+ margin?: Partial<ChartMargin>
120
+ /** Accessible text alternative, forwarded to `ChartFrame` (+ `role="group"`) and to the
121
+ * focusable hover overlay's slider label. */
122
+ ariaLabel?: string
123
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc. */
124
+ isPending?: boolean
125
+ }
126
+
127
+ const DEFAULT_MARKER_INSET = 6
128
+
129
+ /** Rendered by a `formatValue` that returned `null` — an absent reading, told apart from a state
130
+ * whose name is the whole reading (which renders no value at all). */
131
+ const NO_READING = '\u2014'
132
+
133
+ /**
134
+ * The one band treatment that belongs to no legend entry. A dashed neutral outline appears nowhere
135
+ * else on a strip — not as a state fill, not as the absence hatch — so an unresolvable key reads as
136
+ * "unnamed", never as data.
137
+ */
138
+ const UNKNOWN_MARK = {
139
+ fill: alpha(VX.neutral, 0.2),
140
+ stroke: VX.neutral,
141
+ strokeWidth: 1,
142
+ strokeDasharray: '3 2',
143
+ } as const
144
+
145
+ /** The house dev gate (`src/provider`, `src/agent-chat` use the same expression): `basaltViteConfig`
146
+ * defines `process.env.NODE_ENV`, so a production bundle constant-folds this to `false` and drops
147
+ * the throw. Read per call, never hoisted to a module const, so a test can flip it. */
148
+ function isDev(): boolean {
149
+ return process.env['NODE_ENV'] !== 'production'
150
+ }
151
+
152
+ /**
153
+ * A `state` (or `marker.state`) naming no `series` entry.
154
+ *
155
+ * Dev throws, production draws {@link UNKNOWN_MARK}: `state` comes off the DATUM, so a feed that
156
+ * grows a state basalt has never seen must degrade to an honest unknown rather than take the page
157
+ * down — while a typo, which is the same input, still fails loudly everywhere it is being written.
158
+ * The one outcome ruled out in both is the old one: dropping the band, which renders as absence.
159
+ */
160
+ function unknownStateError(what: string, key: string, known: Iterable<string>): Error {
161
+ return new Error(
162
+ `BandStrip: ${what} "${key}" names no \`series\` entry (known: ${[...known].join(', ')}) — so ` +
163
+ 'it has no legend entry, no colour and no tooltip row. Add the state to `series`, or map the ' +
164
+ 'datum onto one that is there.',
165
+ )
166
+ }
167
+
168
+ /** A band's tooltip value: `null` from `formatValue` is an absent reading, `undefined` (no
169
+ * `formatValue` at all) is a state whose label is the whole row. */
170
+ function formatBandValue<T>(style: BandStripSeries<T>, datum: T): string {
171
+ if (style.formatValue === undefined) return ''
172
+ return style.formatValue(datum) ?? NO_READING
173
+ }
174
+
175
+ /**
176
+ * A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
177
+ *
178
+ * `CartesianChart` cannot host this. It renders `AxisLeftNumeric` unconditionally, so composing it
179
+ * would draw a numeric axis over a chart that measures nothing vertically — and its `scalePoint` x
180
+ * scale has positions but no widths, which is the whole content of a strip. This is the
181
+ * non-single-plot escape `docs/CHARTS-SPEC.md` § "The contract" describes, assembled from the same
182
+ * parts every other chart gets (`ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat`)
183
+ * via `useBandPlot`, never re-implemented.
184
+ *
185
+ * The three qualifications on {@link BandSpan} — `fill`, `absentFraction`, `marker` — are the ones
186
+ * a state strip cannot do without: an intensity ramp inside a state, the share of a folded slot
187
+ * nothing measured, and a fact that must not be read off the ramp.
188
+ */
189
+ function BandStripInner<T>(props: BandStripProps<T>) {
190
+ const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
191
+
192
+ return (
193
+ <ChartFrame
194
+ series={series}
195
+ chartId={chartId}
196
+ {...(height !== undefined && { height })}
197
+ {...(fill !== undefined && { fill })}
198
+ {...(ariaLabel !== undefined && { ariaLabel })}
199
+ {...(isPending !== undefined && { isPending })}
200
+ legend={resolveLegend(legend)}
201
+ >
202
+ {(plot) => <BandStripPlot {...props} plot={plot} />}
203
+ </ChartFrame>
204
+ )
205
+ }
206
+
207
+ type BandStripPlotProps<T> = BandStripProps<T> & {
208
+ plot: { width: number; height: number; hidden: ReadonlySet<string> }
209
+ }
210
+
211
+ /** The measured plot — split from {@link BandStripInner} so its scale/cursor hooks only run once
212
+ * `ChartFrame` has resolved a non-empty plot rect (parity with `DualPanel`). */
213
+ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
214
+ const {
215
+ data,
216
+ chartId,
217
+ getX,
218
+ series,
219
+ getBand,
220
+ formatX = fmtAxisDate,
221
+ xTickValues,
222
+ fold,
223
+ cursorResolution = 'leading',
224
+ tooltip,
225
+ absentState,
226
+ margin: marginOverride,
227
+ ariaLabel,
228
+ plot,
229
+ } = props
230
+
231
+ const { hidden } = plot
232
+ const styleByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
233
+
234
+ const band = useBandPlot<T>({
235
+ data,
236
+ chartId,
237
+ getX,
238
+ formatX,
239
+ width: plot.width,
240
+ cursorResolution,
241
+ ...(fold !== undefined && { fold }),
242
+ ...(xTickValues !== undefined && { xTickValues }),
243
+ ...(marginOverride !== undefined && { margin: marginOverride }),
244
+ ...(tooltip !== undefined && { tooltip }),
245
+ })
246
+
247
+ const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
248
+ const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
249
+ const hatchId = `${chartId}-band-absent`
250
+ if (absentState !== undefined && !styleByKey.has(absentState)) {
251
+ throw unknownStateError('absentState', absentState, styleByKey.keys())
252
+ }
253
+ const absentColor =
254
+ (absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
255
+
256
+ const rects = useMemo<ReactNode[]>(() => {
257
+ const out: ReactNode[] = []
258
+ bands.forEach((d, i) => {
259
+ const span = getBand(d)
260
+ const style = styleByKey.get(span.state)
261
+ const x = i * step
262
+ if (style === undefined) {
263
+ if (isDev()) throw unknownStateError('BandSpan.state', span.state, styleByKey.keys())
264
+ out.push(
265
+ <rect
266
+ key={getX(d)}
267
+ {...UNKNOWN_MARK}
268
+ x={x}
269
+ y={0}
270
+ width={bandWidth}
271
+ height={stripHeight}
272
+ rx={1}
273
+ pointerEvents="none"
274
+ />,
275
+ )
276
+ return
277
+ }
278
+ if (hidden.has(span.state)) return
279
+ // Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
280
+ // 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
281
+ // straight into `width="NaN"`, a band that silently fails to paint.
282
+ const absent = clampFraction(span.absentFraction)
283
+ const measuredWidth = bandWidth * (1 - absent)
284
+ const hatchWidth = bandWidth - measuredWidth
285
+ const markerKey = span.marker?.state ?? span.state
286
+ const markerColor = styleByKey.get(markerKey)?.color
287
+ if (span.marker !== undefined && markerColor === undefined && isDev()) {
288
+ throw unknownStateError('BandSpan.marker.state', markerKey, styleByKey.keys())
289
+ }
290
+ const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
291
+ out.push(
292
+ <g key={getX(d)}>
293
+ {measuredWidth > 0 && (
294
+ <rect
295
+ x={x}
296
+ y={0}
297
+ width={measuredWidth}
298
+ height={stripHeight}
299
+ rx={1}
300
+ fill={span.fill ?? alpha(style.color, style.fillOpacity ?? 1)}
301
+ pointerEvents="none"
302
+ />
303
+ )}
304
+ {hatchWidth > 0 && (
305
+ <rect
306
+ x={x + measuredWidth}
307
+ y={0}
308
+ width={hatchWidth}
309
+ height={stripHeight}
310
+ rx={1}
311
+ fill={hatchFill(hatchId)}
312
+ pointerEvents="none"
313
+ />
314
+ )}
315
+ {span.marker !== undefined && measuredWidth > 0 && (
316
+ <rect
317
+ {...(markerColor === undefined ? UNKNOWN_MARK : { fill: markerColor })}
318
+ x={x}
319
+ y={inset}
320
+ width={measuredWidth}
321
+ height={Math.max(stripHeight - 2 * inset, 2)}
322
+ rx={1}
323
+ pointerEvents="none"
324
+ />
325
+ )}
326
+ </g>,
327
+ )
328
+ })
329
+ return out
330
+ }, [bands, getBand, getX, styleByKey, hidden, bandWidth, step, stripHeight, hatchId])
331
+
332
+ if (plotWidth <= 0 || bands.length === 0) return null
333
+
334
+ const cfg = tooltip === false ? undefined : tooltip
335
+ const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
336
+ const row = point === null ? null : deriveBandRow(getBand(point), styleByKey, hidden, point)
337
+
338
+ return (
339
+ <>
340
+ <svg ref={band.svgRef} width={plot.width} height={plot.height}>
341
+ <defs>
342
+ <HatchPattern
343
+ id={hatchId}
344
+ color={absentColor}
345
+ opacity={0.7}
346
+ size={hatchSizeFor(bandWidth)}
347
+ />
348
+ </defs>
349
+ {/* theme-allow-file basalt/hand-rolled-plot — a strip has ONE dimension: slots over a
350
+ category axis, no numeric y at all. `CartesianChart`'s contract is a single plot rect
351
+ with one or two numeric y axes and it renders `AxisLeftNumeric` unconditionally, so it
352
+ cannot express this shape. The plot is assembled here from the SAME parts every other
353
+ chart gets — `ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through
354
+ `useBandPlot`, which `MirroredBars` composes too rather than copying this file. */}
355
+ <Group left={margin.left} top={margin.top}>
356
+ {rects}
357
+
358
+ {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={stripHeight} />}
359
+
360
+ <AxisBottomDate
361
+ top={stripHeight}
362
+ scale={scale}
363
+ tickValues={band.tickValues}
364
+ tickFormat={(v) => formatX(String(v))}
365
+ />
366
+
367
+ <HoverOverlay
368
+ width={plotWidth}
369
+ height={stripHeight}
370
+ onMove={cursor.onPointerMove}
371
+ onLeave={cursor.onPointerLeave}
372
+ onKeyDown={cursor.onKeyDown}
373
+ onBlur={cursor.onBlur}
374
+ valueMax={Math.max(bands.length - 1, 0)}
375
+ {...(point !== null && {
376
+ valueNow: bands.indexOf(point),
377
+ valueText: formatX(getX(point)),
378
+ })}
379
+ {...(ariaLabel !== undefined && { ariaLabel })}
380
+ />
381
+ </Group>
382
+ </svg>
383
+
384
+ {band.showTooltip && point !== null && (
385
+ <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
386
+ <TooltipHeader
387
+ date={getX(point)}
388
+ {...(cfg?.formatHeader !== undefined && {
389
+ format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
390
+ })}
391
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
392
+ />
393
+ <TooltipBody>
394
+ {cfg?.prependRows?.(point, { hidden })}
395
+ {row !== null && (
396
+ <TooltipRow
397
+ color={row.color}
398
+ label={row.label}
399
+ value={row.value}
400
+ shape={row.shape}
401
+ dashed={row.dashed}
402
+ />
403
+ )}
404
+ {cfg?.extraRows?.(point, { hidden })}
405
+ </TooltipBody>
406
+ </ChartTooltipFloat>
407
+ )}
408
+ </>
409
+ )
410
+ }
411
+
412
+ /** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
413
+ * end, since the alternative is a band that does not render at all. */
414
+ function clampFraction(value: number | undefined): number {
415
+ if (value === undefined || !Number.isFinite(value)) return 0
416
+ return Math.min(Math.max(value, 0), 1)
417
+ }
418
+
419
+ /**
420
+ * The strip's one derived row: the hovered band's STATE, named and formatted by its own `series`
421
+ * entry. A strip cannot show a row for a state it does not draw, and cannot name a state the
422
+ * legend does not carry — the same guarantee `deriveTooltipRows` gives a cartesian chart, over a
423
+ * shape that has no numeric value to derive from.
424
+ */
425
+ function deriveBandRow<T>(
426
+ span: BandSpan,
427
+ styleByKey: ReadonlyMap<string, BandStripSeries<T>>,
428
+ hidden: ReadonlySet<string>,
429
+ datum: T,
430
+ ): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
431
+ const style = styleByKey.get(span.state)
432
+ // Production-only: dev threw while drawing. The row names the KEY rather than a state, so the
433
+ // tooltip cannot read back a state the legend does not carry.
434
+ if (style === undefined) {
435
+ return {
436
+ color: VX.neutral,
437
+ label: 'Unknown state',
438
+ value: span.state,
439
+ shape: 'bar',
440
+ dashed: true,
441
+ }
442
+ }
443
+ if (style.tooltip === false || hidden.has(span.state)) return null
444
+ return {
445
+ color: style.color,
446
+ label: style.label,
447
+ value: formatBandValue(style, datum),
448
+ shape: style.mark === 'line' ? 'line' : 'bar',
449
+ dashed: style.dash === 'dashed',
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
455
+ * `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
456
+ */
457
+ 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')