basalt-ui 1.8.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 (72) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/agent/rules/basalt-charts.md +74 -5
  3. package/dist/charts/hooks/useHoverSync.d.ts +18 -2
  4. package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
  5. package/dist/charts/hooks/useHoverSync.js +12 -7
  6. package/dist/charts/index.d.ts +1 -0
  7. package/dist/charts/index.d.ts.map +1 -1
  8. package/dist/charts/index.js +6 -0
  9. package/dist/charts/kinds/Bars.d.ts +2 -0
  10. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  11. package/dist/charts/kinds/Bars.js +3 -1
  12. package/dist/charts/kinds/Donut.d.ts +2 -0
  13. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  14. package/dist/charts/kinds/Donut.js +8 -4
  15. package/dist/charts/kinds/DualPanel.d.ts +2 -0
  16. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  17. package/dist/charts/kinds/DualPanel.js +2 -1
  18. package/dist/charts/kinds/Heatmap.d.ts +8 -0
  19. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  20. package/dist/charts/kinds/Heatmap.js +7 -3
  21. package/dist/charts/kinds/MultiLine.d.ts +2 -0
  22. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  23. package/dist/charts/kinds/MultiLine.js +2 -1
  24. package/dist/charts/kinds/StackedArea.d.ts +2 -0
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +2 -1
  27. package/dist/charts/kinds/ZonedLine.d.ts +2 -0
  28. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.js +2 -1
  30. package/dist/charts/primitives/Axes.d.ts +6 -1
  31. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  32. package/dist/charts/primitives/Axes.js +3 -2
  33. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  34. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  35. package/dist/charts/primitives/ChartFrame.js +8 -4
  36. package/dist/charts/primitives/ChartPending.d.ts +34 -0
  37. package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
  38. package/dist/charts/primitives/ChartPending.js +23 -0
  39. package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
  40. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  41. package/dist/charts/primitives/ChartTooltip.js +6 -1
  42. package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
  43. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  44. package/dist/charts/primitives/HoverOverlay.js +4 -2
  45. package/dist/cli/index.d.ts +22 -1
  46. package/dist/cli/index.d.ts.map +1 -1
  47. package/dist/cli/index.js +86 -5
  48. package/dist/dashboard/stat-card.d.ts.map +1 -1
  49. package/dist/guard/index.d.ts.map +1 -1
  50. package/dist/guard/index.js +13 -5
  51. package/llms.txt +2 -2
  52. package/package.json +3 -5
  53. package/src/charts/hooks/useHoverSync.ts +43 -7
  54. package/src/charts/index.ts +6 -0
  55. package/src/charts/kinds/Bars.tsx +4 -0
  56. package/src/charts/kinds/Donut.tsx +10 -4
  57. package/src/charts/kinds/DualPanel.tsx +5 -1
  58. package/src/charts/kinds/Heatmap.tsx +19 -2
  59. package/src/charts/kinds/MultiLine.tsx +4 -1
  60. package/src/charts/kinds/StackedArea.tsx +4 -1
  61. package/src/charts/kinds/ZonedLine.tsx +4 -1
  62. package/src/charts/primitives/Axes.tsx +7 -1
  63. package/src/charts/primitives/ChartFrame.test.tsx +64 -0
  64. package/src/charts/primitives/ChartFrame.tsx +17 -4
  65. package/src/charts/primitives/ChartPending.tsx +59 -0
  66. package/src/charts/primitives/ChartTooltip.tsx +15 -3
  67. package/src/charts/primitives/HoverOverlay.tsx +12 -6
  68. package/src/cli/index.ts +154 -5
  69. package/src/cli/placement-engine.test.ts +104 -0
  70. package/src/dashboard/stat-card.tsx +12 -0
  71. package/src/guard/check-source.test.ts +20 -0
  72. package/src/guard/index.ts +20 -9
@@ -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
  }