basalt-ui 1.7.0 → 1.9.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 (77) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/agent/rules/basalt-charts.md +74 -5
  3. package/agent/skills/basalt-design/SKILL.md +5 -2
  4. package/dist/charts/hooks/useHoverSync.d.ts +18 -2
  5. package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
  6. package/dist/charts/hooks/useHoverSync.js +12 -7
  7. package/dist/charts/index.d.ts +1 -0
  8. package/dist/charts/index.d.ts.map +1 -1
  9. package/dist/charts/index.js +6 -0
  10. package/dist/charts/kinds/Bars.d.ts +2 -0
  11. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.js +3 -1
  13. package/dist/charts/kinds/Donut.d.ts +2 -0
  14. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  15. package/dist/charts/kinds/Donut.js +8 -4
  16. package/dist/charts/kinds/DualPanel.d.ts +2 -0
  17. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  18. package/dist/charts/kinds/DualPanel.js +2 -1
  19. package/dist/charts/kinds/Heatmap.d.ts +8 -0
  20. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  21. package/dist/charts/kinds/Heatmap.js +7 -3
  22. package/dist/charts/kinds/MultiLine.d.ts +2 -0
  23. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  24. package/dist/charts/kinds/MultiLine.js +2 -1
  25. package/dist/charts/kinds/StackedArea.d.ts +2 -0
  26. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.js +2 -1
  28. package/dist/charts/kinds/ZonedLine.d.ts +2 -0
  29. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  30. package/dist/charts/kinds/ZonedLine.js +2 -1
  31. package/dist/charts/primitives/Axes.d.ts +6 -1
  32. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  33. package/dist/charts/primitives/Axes.js +3 -2
  34. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  35. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  36. package/dist/charts/primitives/ChartFrame.js +8 -4
  37. package/dist/charts/primitives/ChartPending.d.ts +34 -0
  38. package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
  39. package/dist/charts/primitives/ChartPending.js +23 -0
  40. package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
  41. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  42. package/dist/charts/primitives/ChartTooltip.js +6 -1
  43. package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
  44. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  45. package/dist/charts/primitives/HoverOverlay.js +4 -2
  46. package/dist/cli/index.d.ts +22 -1
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +86 -5
  49. package/dist/dashboard/stat-card.d.ts +4 -2
  50. package/dist/dashboard/stat-card.d.ts.map +1 -1
  51. package/dist/dashboard/stat-card.js +6 -1
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +13 -5
  54. package/llms.txt +2 -2
  55. package/package.json +3 -5
  56. package/src/charts/hooks/useHoverSync.ts +43 -7
  57. package/src/charts/index.ts +6 -0
  58. package/src/charts/kinds/Bars.tsx +4 -0
  59. package/src/charts/kinds/Donut.tsx +10 -4
  60. package/src/charts/kinds/DualPanel.tsx +5 -1
  61. package/src/charts/kinds/Heatmap.tsx +19 -2
  62. package/src/charts/kinds/MultiLine.tsx +4 -1
  63. package/src/charts/kinds/StackedArea.tsx +4 -1
  64. package/src/charts/kinds/ZonedLine.tsx +4 -1
  65. package/src/charts/primitives/Axes.tsx +7 -1
  66. package/src/charts/primitives/ChartFrame.test.tsx +64 -0
  67. package/src/charts/primitives/ChartFrame.tsx +17 -4
  68. package/src/charts/primitives/ChartPending.tsx +59 -0
  69. package/src/charts/primitives/ChartTooltip.tsx +15 -3
  70. package/src/charts/primitives/HoverOverlay.tsx +12 -6
  71. package/src/cli/index.ts +154 -5
  72. package/src/cli/placement-engine.test.ts +104 -0
  73. package/src/dashboard/stat-card.test.tsx +81 -0
  74. package/src/dashboard/stat-card.tsx +30 -4
  75. package/src/guard/check-source.test.ts +22 -1
  76. package/src/guard/index.ts +21 -7
  77. package/src/theme/contrast.test.ts +18 -1
@@ -24,12 +24,29 @@ export function useHoverSync<T>({
24
24
  getKey,
25
25
  xScale,
26
26
  marginLeft,
27
+ resolveKey,
27
28
  }: {
28
29
  data: T[]
29
30
  chartId: string
30
31
  getKey: (d: T) => string
31
32
  xScale: XScale
32
33
  marginLeft: number
34
+ /**
35
+ * Overrides how a SIBLING's broadcast key resolves to one of THIS chart's own points. Only
36
+ * affects reading another chart's hover — this chart's own hover (via `getKey`/`xScale`) is
37
+ * untouched.
38
+ *
39
+ * Cross-chart sync is exact string match by default (`pointByKey.get(ctx.key)`), so two charts
40
+ * share a cursor only if they emit identical key strings. A chart that downsamples/folds its
41
+ * domain to fit a narrow viewport (e.g. ~288 buckets folded into ~97 columns at 390px) no longer
42
+ * owns most of the keys its unfolded siblings broadcast — the shared crosshair then appears on
43
+ * roughly one hover in three, with no rule a reader can infer, which is worse than no shared
44
+ * cursor at all. Pass `resolveKey` to map a foreign key onto whichever of this chart's own points
45
+ * swallowed it (e.g. the folded bucket the key falls into).
46
+ *
47
+ * Omit it and behaviour is byte-identical to today.
48
+ */
49
+ resolveKey?: (key: string) => T | null
33
50
  }) {
34
51
  const ctx = useContext(HoverContext)
35
52
  // Latest context in a ref so the mouse callbacks stay referentially stable: the provider's value
@@ -53,13 +70,26 @@ export function useHoverSync<T>({
53
70
 
54
71
  const { tip, show, hide, tooltipRef, lastDateRef } = useChartTooltip<T>()
55
72
 
73
+ // Latest accessors in refs, same reasoning as `ctxRef` above: the natural call style is an
74
+ // inline arrow for `getKey`/`xScale`, which is fresh on every render, and this file already
75
+ // pays for that hazard once (see the comment above `ctxRef`) without applying the fix here — a
76
+ // several-hundred-entry `pointByKey` Map was rebuilding on every render of a page that
77
+ // re-renders on a heartbeat. Trade-off: because `getKey` is read through a ref, changing key
78
+ // SEMANTICS without changing `data` will NOT rebuild the map. That's the intended trade — a
79
+ // `getKey` that changes meaning for the same data is pathological; a `getKey` that changes
80
+ // identity every render is the normal case.
81
+ const getKeyRef = useRef(getKey)
82
+ getKeyRef.current = getKey
83
+ const xScaleRef = useRef(xScale)
84
+ xScaleRef.current = xScale
85
+
56
86
  // O(1) lookup of a point by its key. Under a provider every sibling resolves the broadcast
57
87
  // key each frame; an O(n) `data.find` per sibling per move is N×O(M). This makes it N×O(1).
58
88
  const pointByKey = useMemo(() => {
59
89
  const m = new Map<string, T>()
60
- for (const d of data) m.set(getKey(d), d)
90
+ for (const d of data) m.set(getKeyRef.current(d), d)
61
91
  return m
62
- }, [data, getKey])
92
+ }, [data])
63
93
 
64
94
  const handleMouse = useCallback(
65
95
  (event: React.MouseEvent<SVGRectElement>) => {
@@ -69,7 +99,7 @@ export function useHoverSync<T>({
69
99
  let closest: T = data[0] as T
70
100
  let minDist = Infinity
71
101
  for (const d of data) {
72
- const sx = xScale(getKey(d)) ?? 0
102
+ const sx = xScaleRef.current(getKeyRef.current(d)) ?? 0
73
103
  const dist = Math.abs(sx - px)
74
104
  if (dist < minDist) {
75
105
  minDist = dist
@@ -77,13 +107,13 @@ export function useHoverSync<T>({
77
107
  }
78
108
  }
79
109
  show(closest, event)
80
- const key = getKey(closest)
110
+ const key = getKeyRef.current(closest)
81
111
  if (lastDateRef.current !== key) {
82
112
  lastDateRef.current = key
83
113
  ctxRef.current.setHover(key, chartId)
84
114
  }
85
115
  },
86
- [data, xScale, getKey, chartId, marginLeft, show, lastDateRef],
116
+ [data, chartId, marginLeft, show, lastDateRef],
87
117
  )
88
118
 
89
119
  const handleLeave = useCallback(() => {
@@ -97,8 +127,14 @@ export function useHoverSync<T>({
97
127
  // The point the crosshair + synced dots track. Inside a <ChartHoverSync> this follows the
98
128
  // broadcast key (so every sibling paints a ghost crosshair at the same x). WITHOUT a provider the
99
129
  // chart is standalone, so it falls back to THIS chart's own hovered point (`tip.data`) — otherwise
100
- // a chart outside a provider would never draw a crosshair/dots at all.
101
- const syncedPoint = ctx.key ? (pointByKey.get(ctx.key) ?? null) : (tip?.data ?? null)
130
+ // a chart outside a provider would never draw a crosshair/dots at all. When `resolveKey` is
131
+ // supplied it replaces the default exact-match lookup for reading a SIBLING's broadcast key (see
132
+ // its doc comment above) — this chart's own hover is unaffected either way.
133
+ const syncedPoint = ctx.key
134
+ ? resolveKey
135
+ ? resolveKey(ctx.key)
136
+ : (pointByKey.get(ctx.key) ?? null)
137
+ : (tip?.data ?? null)
102
138
  // Which chart owns the floating tooltip. Inside a provider the hovered chart is the one whose id
103
139
  // matches the broadcast source (siblings show a ghost crosshair only). Standalone, its own local
104
140
  // hover (`tip`) drives the tooltip.
@@ -46,6 +46,12 @@ export {
46
46
  // ── Primitives ───────────────────────────────────────────────────────────
47
47
  export { ResponsiveChart, type ResponsiveChartProps } from './primitives/ResponsiveChart'
48
48
  export { ChartFrame, type ChartFrameProps, type ChartFrameLegend } from './primitives/ChartFrame'
49
+ export {
50
+ ChartCenter,
51
+ type ChartCenterProps,
52
+ ChartPending,
53
+ type ChartPendingProps,
54
+ } from './primitives/ChartPending'
49
55
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
50
56
  export { ChartCard } from './primitives/ChartCard'
51
57
  export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
@@ -139,6 +139,8 @@ export type BarsProps<T> = {
139
139
  legend?: ChartLegendConfig | false
140
140
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
141
141
  ariaLabel?: string
142
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
143
+ isPending?: boolean
142
144
  }
143
145
 
144
146
  /**
@@ -164,6 +166,7 @@ function BarsInner<T>(props: BarsProps<T>) {
164
166
  height,
165
167
  legend,
166
168
  ariaLabel,
169
+ isPending,
167
170
  } = props
168
171
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
169
172
 
@@ -207,6 +210,7 @@ function BarsInner<T>(props: BarsProps<T>) {
207
210
  chartId={chartId}
208
211
  {...(height !== undefined && { height })}
209
212
  {...(ariaLabel !== undefined && { ariaLabel })}
213
+ {...(isPending !== undefined && { isPending })}
210
214
  legend={resolveLegend(legend, {
211
215
  highlighted: highlightedKey,
212
216
  onHighlight: setHighlightedKey,
@@ -53,6 +53,8 @@ export type DonutProps<K extends string = SeriesKey> = {
53
53
  padAngle?: number
54
54
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
55
55
  ariaLabel?: string
56
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
57
+ isPending?: boolean
56
58
  }
57
59
 
58
60
  /**
@@ -64,7 +66,7 @@ export type DonutProps<K extends string = SeriesKey> = {
64
66
  * (donut ↔ bar, via the generalized key) is a distinct, deliberately deferred feature.
65
67
  */
66
68
  function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
67
- const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel } = props
69
+ const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel, isPending } = props
68
70
 
69
71
  const series: SeriesStyle[] = data.map((d) => ({
70
72
  key: d.key,
@@ -78,6 +80,7 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
78
80
  series={series}
79
81
  {...(height !== undefined && { height })}
80
82
  {...(ariaLabel !== undefined && { ariaLabel })}
83
+ {...(isPending !== undefined && { isPending })}
81
84
  >
82
85
  {(plot) => <DonutPlot {...props} plot={plot} />}
83
86
  </ChartFrame>
@@ -136,13 +139,16 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
136
139
  return (
137
140
  <g
138
141
  key={key}
139
- onMouseEnter={(event) => {
142
+ onPointerEnter={(event) => {
140
143
  show(arc.data, event)
141
144
  }}
142
- onMouseMove={(event) => {
145
+ onPointerMove={(event) => {
143
146
  show(arc.data, event)
144
147
  }}
145
- onMouseLeave={() => {
148
+ onPointerLeave={() => {
149
+ hide()
150
+ }}
151
+ onPointerCancel={() => {
146
152
  hide()
147
153
  }}
148
154
  style={{ cursor: 'pointer' }}
@@ -57,6 +57,8 @@ export type DualPanelProps<T> = {
57
57
  legend?: ChartLegendConfig | false
58
58
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
59
59
  ariaLabel?: string
60
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
61
+ isPending?: boolean
60
62
  }
61
63
 
62
64
  const PANE_GAP = 12
@@ -74,7 +76,8 @@ const PANE_GAP = 12
74
76
  * when a series has nulls; lines/bars skip null points (visual gaps).
75
77
  */
76
78
  function DualPanelInner<T>(props: DualPanelProps<T>) {
77
- const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel } = props
79
+ const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel, isPending } =
80
+ props
78
81
 
79
82
  // Default the top-pane line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) —
80
83
  // applied once here so the plotted line, the derived legend swatch, and the derived tooltip row
@@ -101,6 +104,7 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
101
104
  chartId={chartId}
102
105
  {...(height !== undefined && { height })}
103
106
  {...(ariaLabel !== undefined && { ariaLabel })}
107
+ {...(isPending !== undefined && { isPending })}
104
108
  legend={resolveLegend(legend)}
105
109
  >
106
110
  {(plot) => <DualPanelPlot {...props} series={styledSeries} plot={plot} />}
@@ -8,6 +8,7 @@ import {
8
8
  TooltipRow,
9
9
  useTooltipStyles,
10
10
  } from '../primitives/ChartTooltip'
11
+ import { ChartPending } from '../primitives/ChartPending'
11
12
  import { useChartTooltip } from '../hooks/useChartTooltip'
12
13
  import { VX, alpha } from '../../tokens'
13
14
 
@@ -47,6 +48,14 @@ export type HeatmapProps<T> = {
47
48
  renderTooltip?: (cell: HeatmapCell) => ReactNode
48
49
  /** Optional gradient legend strip below the grid (faint → solid color). */
49
50
  legend?: { min: string; max: string }
51
+ /**
52
+ * The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
53
+ * three-state rationale) over the full `width` × `height` box in place of the grid, suppressing
54
+ * the legend strip and every cell/label/tooltip along with it. Unlike the other six kinds,
55
+ * `Heatmap` doesn't compose `ChartFrame` (it takes `width`/`height` directly rather than
56
+ * measuring), so this is handled locally instead of forwarded.
57
+ */
58
+ isPending?: boolean
50
59
  }
51
60
 
52
61
  /** Build a first-seen-ordered list of unique keys from data via an accessor. */
@@ -106,6 +115,7 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
106
115
  colLabel = (c) => c,
107
116
  renderTooltip,
108
117
  legend,
118
+ isPending,
109
119
  } = props
110
120
 
111
121
  const tooltipStyles = useTooltipStyles()
@@ -126,6 +136,12 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
126
136
  return { lookup: map, max: m }
127
137
  }, [data, getRow, getCol, getValue])
128
138
 
139
+ // Every hook above must still run on a pending render (Rules of Hooks) — the early return comes
140
+ // after them. No `ChartFrame` to forward to here (Heatmap measures via required `width`/`height`
141
+ // props, not `useChartSize`), so the placeholder is rendered directly in its place, same footprint,
142
+ // legend strip and all other content omitted along with it.
143
+ if (isPending) return <ChartPending width={width} height={height} />
144
+
129
145
  const legendH = legend ? LEGEND_H + LEGEND_LABEL_H : 0
130
146
  const gridW = Math.max(0, width - PAD_LEFT)
131
147
  const gridH = Math.max(0, height - PAD_TOP - PAD_BOTTOM - legendH)
@@ -152,8 +168,9 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
152
168
  rx={cellRadius}
153
169
  fill={fill}
154
170
  style={{ cursor: has ? 'pointer' : 'default' }}
155
- onMouseMove={(e) => has && show({ row, col, value }, e)}
156
- onMouseLeave={hide}
171
+ onPointerMove={(e) => has && show({ row, col, value }, e)}
172
+ onPointerLeave={hide}
173
+ onPointerCancel={hide}
157
174
  />
158
175
  )
159
176
  }),
@@ -67,6 +67,8 @@ export type MultiLineProps<T> = {
67
67
  legend?: ChartLegendConfig | false
68
68
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
69
69
  ariaLabel?: string
70
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
71
+ isPending?: boolean
70
72
  }
71
73
 
72
74
  const STAR_R = 6
@@ -96,7 +98,7 @@ function starPath(cx: number, cy: number, r: number): string {
96
98
  * has nulls; each series line skips null points (creating visual gaps).
97
99
  */
98
100
  function MultiLineInner<T>(props: MultiLineProps<T>) {
99
- const { series, chartId, height, legend, ariaLabel } = props
101
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
100
102
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
101
103
 
102
104
  // Default line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied once
@@ -112,6 +114,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
112
114
  chartId={chartId}
113
115
  {...(height !== undefined && { height })}
114
116
  {...(ariaLabel !== undefined && { ariaLabel })}
117
+ {...(isPending !== undefined && { isPending })}
115
118
  legend={resolveLegend(legend, {
116
119
  highlighted: highlightedKey,
117
120
  onHighlight: setHighlightedKey,
@@ -40,6 +40,8 @@ export type StackedAreaProps<T> = {
40
40
  legend?: ChartLegendConfig | false
41
41
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
42
42
  ariaLabel?: string
43
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
44
+ isPending?: boolean
43
45
  }
44
46
 
45
47
  /**
@@ -48,7 +50,7 @@ export type StackedAreaProps<T> = {
48
50
  * The tooltip renders rows in reversed (top-to-bottom) stack order so it matches the visual stack.
49
51
  */
50
52
  function StackedAreaInner<T>(props: StackedAreaProps<T>) {
51
- const { series, chartId, height, legend, ariaLabel } = props
53
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
52
54
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
53
55
 
54
56
  return (
@@ -57,6 +59,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
57
59
  chartId={chartId}
58
60
  {...(height !== undefined && { height })}
59
61
  {...(ariaLabel !== undefined && { ariaLabel })}
62
+ {...(isPending !== undefined && { isPending })}
60
63
  legend={resolveLegend(legend, {
61
64
  highlighted: highlightedKey,
62
65
  onHighlight: setHighlightedKey,
@@ -101,6 +101,8 @@ export type ZonedLineProps<T> = {
101
101
  legend?: ChartLegendConfig | false
102
102
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
103
103
  ariaLabel?: string
104
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
105
+ isPending?: boolean
104
106
  }
105
107
 
106
108
  /**
@@ -116,7 +118,7 @@ export type ZonedLineProps<T> = {
116
118
  * visual gaps).
117
119
  */
118
120
  function ZonedLineInner<T>(props: ZonedLineProps<T>) {
119
- const { series, chartId, height, legend, ariaLabel } = props
121
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
120
122
 
121
123
  // Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
122
124
  // once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
@@ -131,6 +133,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
131
133
  chartId={chartId}
132
134
  {...(height !== undefined && { height })}
133
135
  {...(ariaLabel !== undefined && { ariaLabel })}
136
+ {...(isPending !== undefined && { isPending })}
134
137
  legend={resolveLegend(legend)}
135
138
  >
136
139
  {(plot) => <ZonedLinePlot {...props} series={styledSeries} plot={plot} />}
@@ -73,17 +73,23 @@ export function AxisBottomDate({
73
73
  scale,
74
74
  top,
75
75
  tickValues,
76
+ tickFormat = fmtAxisDate,
76
77
  }: {
77
78
  scale: AxisScale
78
79
  top: number
79
80
  tickValues: string[]
81
+ /**
82
+ * Defaults to `fmtAxisDate` (DD.MM). Override for a sub-day window, where DD.MM collapses every
83
+ * tick to the same label.
84
+ */
85
+ tickFormat?: TickFormatter<string>
80
86
  }) {
81
87
  return (
82
88
  <AxisBottom
83
89
  top={top}
84
90
  scale={scale}
85
91
  tickValues={tickValues}
86
- tickFormat={fmtAxisDate}
92
+ tickFormat={tickFormat}
87
93
  tickLabelProps={{
88
94
  fill: VX.faint,
89
95
  fontFamily: TICK_FONT_FAMILY,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `ChartFrame`'s `isPending` invariant — the one behavior here that regresses silently. A pending
3
+ * chart that quietly fell back to rendering its legend + body would put a densified "not measured"
4
+ * shape back on screen exactly where `ChartPending` is supposed to reserve a static, contentless
5
+ * placeholder (see `ChartPending`'s JSDoc for the three-state "nothing to draw" rationale).
6
+ *
7
+ * No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
8
+ * `renderToStaticMarkup` is enough here: `ChartFrame`/`ChartPending` live in `src/charts/**`, which
9
+ * is Mantine-free, so no `MantineProvider` wrapper is needed either. `useChartSize` never measures
10
+ * under SSR (no `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed
11
+ * height — non-zero, which is what lets `children`/`ChartPending` render at all in this harness.
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { renderToStaticMarkup } from 'react-dom/server'
15
+ import { ChartFrame } from './ChartFrame'
16
+ import type { SeriesStyle } from '../series'
17
+
18
+ const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
19
+
20
+ const CHART_BODY_MARKER = 'CHART_BODY_MARKER'
21
+
22
+ function renderFrame(isPending: boolean): string {
23
+ return renderToStaticMarkup(
24
+ <ChartFrame series={series} legend={{}} isPending={isPending}>
25
+ {() => <svg>{CHART_BODY_MARKER}</svg>}
26
+ </ChartFrame>,
27
+ )
28
+ }
29
+
30
+ describe('a pending ChartFrame renders neither the legend nor the chart body', () => {
31
+ const markup = renderFrame(true)
32
+
33
+ test('the legend is absent — no series label anywhere in the markup', () => {
34
+ expect(markup).not.toContain('Series A')
35
+ })
36
+
37
+ test('the chart body (children) never runs', () => {
38
+ expect(markup).not.toContain(CHART_BODY_MARKER)
39
+ })
40
+
41
+ test('the outer container is marked aria-busy', () => {
42
+ expect(markup).toContain('aria-busy="true"')
43
+ })
44
+
45
+ test('the placeholder label renders in its place', () => {
46
+ expect(markup).toContain('Loading…')
47
+ })
48
+ })
49
+
50
+ describe('a non-pending ChartFrame is unaffected — the legend and body both render', () => {
51
+ const markup = renderFrame(false)
52
+
53
+ test('the legend renders the series label', () => {
54
+ expect(markup).toContain('Series A')
55
+ })
56
+
57
+ test('the chart body renders', () => {
58
+ expect(markup).toContain(CHART_BODY_MARKER)
59
+ })
60
+
61
+ test('no aria-busy attribute is present', () => {
62
+ expect(markup).not.toContain('aria-busy')
63
+ })
64
+ })
@@ -3,6 +3,7 @@ import { useChartSize } from '../hooks/useChartSize'
3
3
  import { deriveLegend } from '../series'
4
4
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
5
5
  import { ChartLegend } from './ChartLegend'
6
+ import { ChartPending } from './ChartPending'
6
7
 
7
8
  const DEFAULT_HEIGHT = 240
8
9
  const DEFAULT_MIN_WIDTH = 200
@@ -36,6 +37,13 @@ export type ChartFrameProps = {
36
37
  chartId?: string
37
38
  /** `false` only for the sparkline exemption — every other chart gets a legend by default. */
38
39
  legend?: ChartFrameLegend | false
40
+ /**
41
+ * The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
42
+ * three-state "nothing to draw" rationale) over the full plot rect in place of `children`,
43
+ * suppresses the legend entirely (a legend naming a series with nothing yet to point at is its
44
+ * own small lie), and marks the outer container `aria-busy="true"`.
45
+ */
46
+ isPending?: boolean
39
47
  /**
40
48
  * Accessible text alternative for the chart, applied as `aria-label` (+ `role="img"`) on the
41
49
  * outer container so screen readers announce something other than an unlabeled graphic. Every
@@ -106,6 +114,7 @@ export function ChartFrame({
106
114
  chartId,
107
115
  legend = {},
108
116
  ariaLabel,
117
+ isPending = false,
109
118
  children,
110
119
  }: ChartFrameProps): ReactNode {
111
120
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
@@ -113,6 +122,7 @@ export function ChartFrame({
113
122
 
114
123
  const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
115
124
  const vertical = placement === 'left' || placement === 'right'
125
+ const legendVisible = legend !== false && !isPending
116
126
 
117
127
  const resolvedHeight = fill
118
128
  ? containerH
@@ -120,8 +130,8 @@ export function ChartFrame({
120
130
  ? Math.round(containerW / aspectRatio)
121
131
  : (height ?? DEFAULT_HEIGHT)
122
132
 
123
- const sideLegendWidth = legend !== false && vertical ? legendW : 0
124
- const topBottomLegendHeight = legend !== false && !vertical ? legendH : 0
133
+ const sideLegendWidth = legendVisible && vertical ? legendW : 0
134
+ const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
125
135
 
126
136
  const plot = {
127
137
  width: Math.max(containerW - sideLegendWidth, minWidth),
@@ -129,7 +139,7 @@ export function ChartFrame({
129
139
  }
130
140
 
131
141
  const legendNode =
132
- legend === false ? null : (
142
+ legend === false || isPending ? null : (
133
143
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
134
144
  <ChartLegend
135
145
  items={deriveLegend(series)}
@@ -148,9 +158,12 @@ export function ChartFrame({
148
158
  ref={containerRef}
149
159
  style={outerStyle(fill, vertical)}
150
160
  {...(ariaLabel !== undefined && { role: 'img', 'aria-label': ariaLabel })}
161
+ {...(isPending && { 'aria-busy': 'true' })}
151
162
  >
152
163
  {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
153
- {plot.width > 0 && plot.height > 0 ? children(plot) : null}
164
+ {plot.width > 0 &&
165
+ plot.height > 0 &&
166
+ (isPending ? <ChartPending width={plot.width} height={plot.height} /> : children(plot))}
154
167
  {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
155
168
  </div>
156
169
  )
@@ -0,0 +1,59 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { VX } from '../../tokens'
3
+
4
+ export type ChartCenterProps = {
5
+ width: number
6
+ height: number
7
+ children: ReactNode
8
+ }
9
+
10
+ const centerStyle = (width: number, height: number): CSSProperties => ({
11
+ width,
12
+ height,
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ justifyContent: 'center',
16
+ })
17
+
18
+ /**
19
+ * Minimal layout primitive: centers `children` inside a `width` × `height` box. Exists because
20
+ * `src/charts/**` cannot import Mantine's `Flex`/`Group`/`Center` (the Mantine-free boundary), so
21
+ * there was previously nothing to reach for when a chart file needed to center something. Not a
22
+ * general layout system — width, height, children, nothing else.
23
+ */
24
+ export function ChartCenter({ width, height, children }: ChartCenterProps): ReactNode {
25
+ return <div style={centerStyle(width, height)}>{children}</div>
26
+ }
27
+
28
+ export type ChartPendingProps = {
29
+ width: number
30
+ height: number
31
+ /** Default `'Loading…'`. */
32
+ label?: string
33
+ }
34
+
35
+ const labelStyle: CSSProperties = {
36
+ color: VX.faint,
37
+ fontSize: VX.text.sm,
38
+ }
39
+
40
+ /**
41
+ * The placeholder for a chart whose data hasn't arrived yet — the third "nothing to draw" state
42
+ * alongside measured-and-empty and measured-and-absent (a real gap in coverage). Collapsing an
43
+ * in-flight query into either of those (the `data ?? []` idiom) densifies it into a fully-hatched
44
+ * "not measured" window: a positive claim that the series WAS watched and carried nothing, when in
45
+ * fact it was never asked. `ChartPending` makes "not asked yet" its own rendered state instead of
46
+ * borrowing one of the other two.
47
+ *
48
+ * Reserves exactly the plot's footprint and draws NOTHING that could be mistaken for a
49
+ * measurement — no axes, no gridlines, no hatching, no data marks — just a faint, static, centered
50
+ * label. No animation: the package's motion doctrine bans looping/pulsing idle motion, so a static
51
+ * reserved box is the correct answer here, not a compromise.
52
+ */
53
+ export function ChartPending({ width, height, label = 'Loading…' }: ChartPendingProps): ReactNode {
54
+ return (
55
+ <ChartCenter width={width} height={height}>
56
+ <span style={labelStyle}>{label}</span>
57
+ </ChartCenter>
58
+ )
59
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CSSProperties, ReactNode, RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { VX } from '../../tokens'
3
4
  import { fmtTooltipDate } from '../utils/format'
4
5
 
@@ -24,7 +25,10 @@ export function useTooltipStyles(): CSSProperties {
24
25
  return TOOLTIP_STYLES
25
26
  }
26
27
 
27
- /** Outer tooltip shell. Renders nothing when tip is null. */
28
+ /**
29
+ * Outer tooltip shell. Renders nothing when tip is null. Renders to `document.body` via a
30
+ * portal, so it may be authored anywhere in the tree, including inside `<svg>`.
31
+ */
28
32
  export function ChartTooltip({
29
33
  tip,
30
34
  tooltipRef,
@@ -37,10 +41,18 @@ export function ChartTooltip({
37
41
  children: ReactNode
38
42
  }) {
39
43
  if (!tip) return null
40
- return (
44
+ // SSR guard: react-dom/server (renderToStaticMarkup, used by this repo's tests — see
45
+ // dashboard/stat-card.test.tsx) has no jsdom and does not support portals.
46
+ if (typeof document === 'undefined') return null
47
+ // Portal to document.body for two reasons: (1) authoring this inside an <svg> tree would
48
+ // otherwise create the div in the SVG namespace, where it mounts, accepts every prop, and
49
+ // is never painted; (2) TOOLTIP_STYLES' position: 'fixed' breaks inside any transformed
50
+ // ancestor unless the node is portalled out to the body.
51
+ return createPortal(
41
52
  <div ref={tooltipRef} style={{ ...styles, left: tip.x, top: tip.y }}>
42
53
  {children}
43
- </div>
54
+ </div>,
55
+ document.body,
44
56
  )
45
57
  }
46
58
 
@@ -1,6 +1,7 @@
1
- import type { MouseEventHandler } from 'react'
1
+ import type { PointerEventHandler } from 'react'
2
2
 
3
- /** Transparent <rect> that captures mouse events for tooltip + crosshair sync. */
3
+ /** Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
4
+ * sync. */
4
5
  export function HoverOverlay({
5
6
  width,
6
7
  height,
@@ -9,16 +10,21 @@ export function HoverOverlay({
9
10
  }: {
10
11
  width: number
11
12
  height: number
12
- onMove: MouseEventHandler<SVGRectElement>
13
- onLeave: MouseEventHandler<SVGRectElement>
13
+ onMove: PointerEventHandler<SVGRectElement>
14
+ onLeave: PointerEventHandler<SVGRectElement>
14
15
  }) {
15
16
  return (
16
17
  <rect
17
18
  width={width}
18
19
  height={height}
19
20
  fill="transparent"
20
- onMouseMove={onMove}
21
- onMouseLeave={onLeave}
21
+ // `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
22
+ // phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
23
+ // reaches this overlay to scrub the chart.
24
+ style={{ touchAction: 'pan-y' }}
25
+ onPointerMove={onMove}
26
+ onPointerLeave={onLeave}
27
+ onPointerCancel={onLeave}
22
28
  />
23
29
  )
24
30
  }