basalt-ui 1.13.0 → 1.15.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 (180) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/shell/app-sidebar.d.ts +19 -1
  93. package/dist/shell/app-sidebar.d.ts.map +1 -1
  94. package/dist/shell/app-sidebar.js +30 -26
  95. package/dist/shell/app-sidebar.module.css +11 -0
  96. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  97. package/dist/shell/app-sidebar.test.d.ts +2 -0
  98. package/dist/shell/app-sidebar.test.d.ts.map +1 -0
  99. package/dist/shell/index.d.ts +10 -1
  100. package/dist/shell/index.d.ts.map +1 -1
  101. package/dist/shell/index.js +2 -0
  102. package/dist/shell/index.test.d.ts +2 -0
  103. package/dist/shell/index.test.d.ts.map +1 -0
  104. package/dist/surfaces.d.ts +1 -1
  105. package/dist/surfaces.d.ts.map +1 -1
  106. package/dist/surfaces.js +1 -1
  107. package/dist/tokens/chart-margin.test.d.ts +2 -0
  108. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  109. package/dist/tokens/index.d.ts +29 -0
  110. package/dist/tokens/index.d.ts.map +1 -1
  111. package/dist/tokens/index.js +10 -0
  112. package/dist/tokens/palette.d.ts +4 -0
  113. package/dist/tokens/palette.d.ts.map +1 -1
  114. package/dist/tokens/palette.js +3 -0
  115. package/llms.txt +3 -3
  116. package/package.json +1 -1
  117. package/src/charts/cursor/resolve.test.ts +61 -0
  118. package/src/charts/cursor/resolve.ts +102 -0
  119. package/src/charts/cursor/scope.tsx +32 -0
  120. package/src/charts/cursor/store.test.ts +46 -0
  121. package/src/charts/cursor/store.ts +53 -0
  122. package/src/charts/hooks/useChartCursor.ts +163 -0
  123. package/src/charts/hooks/useChartSize.ts +8 -4
  124. package/src/charts/index.ts +34 -14
  125. package/src/charts/kinds/Bars.tsx +327 -460
  126. package/src/charts/kinds/Donut.tsx +31 -23
  127. package/src/charts/kinds/DualPanel.tsx +228 -134
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.tsx +158 -294
  130. package/src/charts/kinds/StackedArea.tsx +116 -175
  131. package/src/charts/kinds/ZonedLine.tsx +148 -255
  132. package/src/charts/layout/auto-margin.test.ts +42 -0
  133. package/src/charts/layout/auto-margin.ts +110 -0
  134. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  135. package/src/charts/primitives/CartesianChart.tsx +566 -0
  136. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  137. package/src/charts/primitives/ChartFrame.tsx +40 -4
  138. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  139. package/src/charts/primitives/ChartLegend.tsx +45 -2
  140. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  141. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  142. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  143. package/src/charts/primitives/XZoneRects.tsx +67 -0
  144. package/src/charts/series.test.ts +61 -0
  145. package/src/charts/series.ts +18 -1
  146. package/src/charts/utils/measure-text.test.ts +36 -0
  147. package/src/charts/utils/measure-text.ts +109 -0
  148. package/src/content/fence-block.tsx +2 -3
  149. package/src/shell/app-sidebar.module.css +11 -0
  150. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  151. package/src/shell/app-sidebar.test.tsx +98 -0
  152. package/src/shell/app-sidebar.tsx +22 -0
  153. package/src/shell/index.test.tsx +35 -0
  154. package/src/shell/index.tsx +11 -0
  155. package/src/surfaces.ts +2 -1
  156. package/src/theme/spacing.test.ts +2 -0
  157. package/src/tokens/chart-margin.test.ts +39 -0
  158. package/src/tokens/density.test.ts +1 -0
  159. package/src/tokens/index.ts +36 -0
  160. package/src/tokens/palette.ts +3 -0
  161. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  162. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  163. package/dist/charts/hooks/useChartTooltip.js +0 -26
  164. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  165. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  166. package/dist/charts/hooks/useHoverSync.js +0 -75
  167. package/dist/charts/hover-context.d.ts +0 -21
  168. package/dist/charts/hover-context.d.ts.map +0 -1
  169. package/dist/charts/hover-context.js +0 -12
  170. package/dist/charts/hover-sync.d.ts +0 -14
  171. package/dist/charts/hover-sync.d.ts.map +0 -1
  172. package/dist/charts/hover-sync.js +0 -17
  173. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  174. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  175. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  176. package/src/charts/hooks/useChartTooltip.ts +0 -31
  177. package/src/charts/hooks/useHoverSync.ts +0 -152
  178. package/src/charts/hover-context.ts +0 -28
  179. package/src/charts/hover-sync.tsx +0 -37
  180. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
@@ -1,4 +1,5 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
+ import { useCallback, useState } from 'react'
2
3
  import { useChartSize } from '../hooks/useChartSize'
3
4
  import { deriveLegend } from '../series'
4
5
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
@@ -20,6 +21,13 @@ export type ChartFrameLegend = {
20
21
  /** Hover-dim wiring lifted from the kind — optional. */
21
22
  highlighted?: string | null
22
23
  onHighlight?: (key: string | null) => void
24
+ /**
25
+ * Clicking a legend entry hides that series. Defaults to true whenever there is more than one
26
+ * entry to toggle between — hiding the only series a chart draws is never useful. The hidden
27
+ * key set reaches the marks through the `children` render prop, so a hidden series leaves the
28
+ * plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
29
+ */
30
+ toggle?: boolean
23
31
  }
24
32
 
25
33
  export type ChartFrameProps = {
@@ -50,8 +58,16 @@ export type ChartFrameProps = {
50
58
  * kind composing `ChartFrame` should accept and forward this from its own props.
51
59
  */
52
60
  ariaLabel?: string
53
- /** Draw the SVG marks given the plot rect that already excludes the legend band. */
54
- children: (plot: { width: number; height: number }) => ReactNode
61
+ /** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
62
+ * of series keys the legend has toggled off. */
63
+ children: (plot: PlotRect) => ReactNode
64
+ }
65
+
66
+ /** What `ChartFrame` hands its child: the resolved plot rect plus legend-toggle state. */
67
+ export type PlotRect = {
68
+ width: number
69
+ height: number
70
+ hidden: ReadonlySet<string>
55
71
  }
56
72
 
57
73
  const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
@@ -89,6 +105,7 @@ export function resolveLegend(
89
105
  placement: config?.placement ?? 'bottom',
90
106
  ...(config?.groups !== undefined && { groups: config.groups }),
91
107
  ...(config?.maxRows !== undefined && { maxRows: config.maxRows }),
108
+ ...(config?.toggle !== undefined && { toggle: config.toggle }),
92
109
  ...(hover !== undefined && { highlighted: hover.highlighted, onHighlight: hover.onHighlight }),
93
110
  }
94
111
  }
@@ -119,6 +136,16 @@ export function ChartFrame({
119
136
  }: ChartFrameProps): ReactNode {
120
137
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
121
138
  const { ref: legendRef, width: legendW, height: legendH } = useChartSize()
139
+ const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
140
+
141
+ const toggleKey = useCallback((key: string) => {
142
+ setHidden((prev) => {
143
+ const next = new Set(prev)
144
+ if (next.has(key)) next.delete(key)
145
+ else next.add(key)
146
+ return next
147
+ })
148
+ }, [])
122
149
 
123
150
  const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
124
151
  const vertical = placement === 'left' || placement === 'right'
@@ -138,12 +165,17 @@ export function ChartFrame({
138
165
  height: resolvedHeight - topBottomLegendHeight,
139
166
  }
140
167
 
168
+ const legendItems = legend === false ? [] : deriveLegend(series)
169
+ const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
170
+
141
171
  const legendNode =
142
172
  legend === false || isPending ? null : (
143
173
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
144
174
  <ChartLegend
145
- items={deriveLegend(series)}
175
+ items={legendItems}
146
176
  placement={placement}
177
+ hidden={hidden}
178
+ {...(togglable && { onToggle: toggleKey })}
147
179
  {...(chartId !== undefined && { chartId })}
148
180
  {...(legend.groups !== undefined && { groups: legend.groups })}
149
181
  {...(legend.maxRows !== undefined && { maxRows: legend.maxRows })}
@@ -163,7 +195,11 @@ export function ChartFrame({
163
195
  {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
164
196
  {plot.width > 0 &&
165
197
  plot.height > 0 &&
166
- (isPending ? <ChartPending width={plot.width} height={plot.height} /> : children(plot))}
198
+ (isPending ? (
199
+ <ChartPending width={plot.width} height={plot.height} />
200
+ ) : (
201
+ children({ ...plot, hidden })
202
+ ))}
167
203
  {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
168
204
  </div>
169
205
  )
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `ChartLegend` — the `note` qualifier. Same SSR harness as `ChartFrame.test.tsx`:
3
+ * `src/charts/**` is Mantine-free, so `renderToStaticMarkup` needs no provider wrapper.
4
+ */
5
+ import { describe, expect, test } from 'bun:test'
6
+ import { renderToStaticMarkup } from 'react-dom/server'
7
+ import { ChartLegend } from './ChartLegend'
8
+ import type { LegendEntry } from './ChartLegend'
9
+
10
+ const BASE: LegendEntry = { key: 'cloud-low', label: 'Low cloud', color: 'var(--vx-fill-1)' }
11
+
12
+ const render = (items: LegendEntry[]): string => renderToStaticMarkup(<ChartLegend items={items} />)
13
+
14
+ describe('ChartLegend note', () => {
15
+ test('renders after the label when set', () => {
16
+ const markup = render([{ ...BASE, note: '0% all night' }])
17
+ expect(markup).toContain('Low cloud')
18
+ expect(markup).toContain('0% all night')
19
+ })
20
+
21
+ test('is absent when unset', () => {
22
+ expect(render([BASE])).not.toContain('<span style="opacity:0.75">')
23
+ })
24
+
25
+ test('an empty note renders no span (truthiness, not !== undefined)', () => {
26
+ expect(render([{ ...BASE, note: '' }])).not.toContain('<span style="opacity:0.75">')
27
+ })
28
+
29
+ test('reaches the accessible name — the explicit aria-label would otherwise replace it', () => {
30
+ const markup = render([{ ...BASE, note: '0% all night' }])
31
+ expect(markup).toContain('aria-label="Low cloud — 0% all night"')
32
+ })
33
+
34
+ test('aria-label stays the bare label when there is no note', () => {
35
+ expect(render([BASE])).toContain('aria-label="Low cloud"')
36
+ })
37
+
38
+ test('a folded companion carries its own note', () => {
39
+ const markup = render([
40
+ {
41
+ ...BASE,
42
+ children: [{ key: 'ma', label: '7d MA', color: 'var(--vx-fill-2)', note: 'est.' }],
43
+ },
44
+ ])
45
+ expect(markup).toContain('7d MA')
46
+ expect(markup).toContain('est.')
47
+ })
48
+ })
@@ -18,6 +18,9 @@ export type LegendEntry = {
18
18
  /** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
19
19
  * subordinate sub-entries beside the parent's label instead of vanishing. */
20
20
  children?: LegendEntry[]
21
+ /** Short qualifier rendered after the label in muted text — e.g. a flat-at-zero series that is
22
+ * invisible in the plot. */
23
+ note?: string
21
24
  }
22
25
 
23
26
  const LEGEND_ITEM_BASE: CSSProperties = {
@@ -44,6 +47,9 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
44
47
 
45
48
  const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
46
49
 
50
+ /** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
51
+ const NO_HIDDEN: ReadonlySet<string> = new Set()
52
+
47
53
  const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
48
54
 
49
55
  /** Order entries series → overlay → reference and record where a divider belongs. */
@@ -174,6 +180,13 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
174
180
  )
175
181
  }
176
182
 
183
+ // Same dimming as `LEGEND_CHILD_STYLE` below — the legend wrapper already sets the base text color
184
+ // to `VX.muted` (`wrapperStyle`), so subordinate text is just that color at reduced opacity, never
185
+ // a second color token.
186
+ const LEGEND_NOTE_STYLE: CSSProperties = {
187
+ opacity: 0.75,
188
+ }
189
+
177
190
  const LEGEND_CHILD_STYLE: CSSProperties = {
178
191
  display: 'flex',
179
192
  alignItems: 'center',
@@ -221,6 +234,9 @@ function LegendChild({ item }: { item: LegendEntry }) {
221
234
  <span style={LEGEND_CHILD_STYLE}>
222
235
  <LegendChildSwatch item={item} />
223
236
  <span>{item.label}</span>
237
+ {/* No LEGEND_NOTE_STYLE here — LEGEND_CHILD_STYLE already dims the whole child block, and
238
+ compounding the two opacities would push a folded companion's note near-invisible. */}
239
+ {item.note ? <span>{item.note}</span> : null}
224
240
  </span>
225
241
  )
226
242
  }
@@ -232,11 +248,17 @@ function LegendChild({ item }: { item: LegendEntry }) {
232
248
  * reference bands instead of one flat smear. `maxRows` caps the number of rendered entries and
233
249
  * rolls the remainder into a `+N more` chip (row-accurate wrap measurement is `ChartFrame`'s job —
234
250
  * this is an entry-count cap, a deliberate simplification until that primitive lands).
251
+ *
252
+ * `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
253
+ * and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
254
+ * §5). Omit it for a static legend; a hidden entry is announced via `aria-pressed`.
235
255
  */
236
256
  export function ChartLegend({
237
257
  items,
238
258
  highlighted = null,
239
259
  onHighlight,
260
+ hidden = NO_HIDDEN,
261
+ onToggle,
240
262
  chartId = '',
241
263
  placement = 'bottom',
242
264
  groups = false,
@@ -245,6 +267,10 @@ export function ChartLegend({
245
267
  items: LegendEntry[]
246
268
  highlighted?: string | null
247
269
  onHighlight?: (key: string | null) => void
270
+ /** Keys currently toggled off. */
271
+ hidden?: ReadonlySet<string>
272
+ /** Present = entries are clickable toggles. */
273
+ onToggle?: (key: string) => void
248
274
  chartId?: string
249
275
  placement?: LegendPlacement
250
276
  groups?: boolean
@@ -255,6 +281,10 @@ export function ChartLegend({
255
281
  if (key !== undefined) onHighlight?.(key)
256
282
  }
257
283
  const handleLeave = () => onHighlight?.(null)
284
+ const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
285
+ const key = e.currentTarget.dataset['legendKey']
286
+ if (key !== undefined) onToggle?.(key)
287
+ }
258
288
 
259
289
  const idPrefix = chartId ? `${chartId}-` : ''
260
290
  const vertical = placement === 'left' || placement === 'right'
@@ -270,11 +300,23 @@ export function ChartLegend({
270
300
  key={item.key}
271
301
  type="button"
272
302
  data-legend-key={item.key}
273
- aria-label={item.label}
303
+ // The note is the whole point for a series that is invisible in the plot, so it has to
304
+ // reach a screen reader too — an explicit aria-label would otherwise replace it.
305
+ aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
306
+ {...(onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
274
307
  style={{
275
308
  ...LEGEND_ITEM_BUTTON,
276
- opacity: highlighted === null || highlighted === item.key ? 1 : 0.3,
309
+ cursor: onToggle === undefined ? 'default' : 'pointer',
310
+ // Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
311
+ // it is the one being hovered.
312
+ opacity: hidden.has(item.key)
313
+ ? 0.35
314
+ : highlighted === null || highlighted === item.key
315
+ ? 1
316
+ : 0.3,
317
+ textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
277
318
  }}
319
+ {...(onToggle !== undefined && { onClick: handleToggle })}
278
320
  onMouseEnter={handleEnter}
279
321
  onMouseLeave={handleLeave}
280
322
  onFocus={handleEnter}
@@ -282,6 +324,7 @@ export function ChartLegend({
282
324
  >
283
325
  <LegendSwatch item={item} idPrefix={idPrefix} />
284
326
  <span>{item.label}</span>
327
+ {item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
285
328
  {item.children?.map((child) => (
286
329
  <LegendChild key={child.key} item={child} />
287
330
  ))}
@@ -1,4 +1,5 @@
1
- import type { CSSProperties, ReactNode, RefObject } from 'react'
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { useLayoutEffect, useRef, useState } from 'react'
2
3
  import { createPortal } from 'react-dom'
3
4
  import { VX } from '../../tokens'
4
5
  import { fmtTooltipDate } from '../utils/format'
@@ -20,42 +21,6 @@ const TOOLTIP_STYLES: CSSProperties = {
20
21
  minWidth: 140,
21
22
  }
22
23
 
23
- /** Theme-aware tooltip container styles — use with useChartTooltip(). */
24
- export function useTooltipStyles(): CSSProperties {
25
- return TOOLTIP_STYLES
26
- }
27
-
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
- */
32
- export function ChartTooltip({
33
- tip,
34
- tooltipRef,
35
- styles,
36
- children,
37
- }: {
38
- tip: { x: number; y: number } | null
39
- tooltipRef?: RefObject<HTMLDivElement | null>
40
- styles: CSSProperties
41
- children: ReactNode
42
- }) {
43
- if (!tip) return null
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(
52
- <div ref={tooltipRef} style={{ ...styles, left: tip.x, top: tip.y }}>
53
- {children}
54
- </div>,
55
- document.body,
56
- )
57
- }
58
-
59
24
  /** Tooltip header — shows formatted date + optional right-aligned label with color. */
60
25
  export function TooltipHeader({
61
26
  date,
@@ -168,3 +133,68 @@ export function TooltipRow({
168
133
  export function TooltipBody({ children }: { children: ReactNode }) {
169
134
  return <div style={{ padding: '5px 0' }}>{children}</div>
170
135
  }
136
+
137
+ /** Gap between the anchor point and the tooltip box, and the minimum distance kept from the
138
+ * viewport edge. */
139
+ const TOOLTIP_GAP = 12
140
+ const VIEWPORT_MARGIN = 8
141
+
142
+ /**
143
+ * Floating tooltip positioned against a viewport-space anchor, with flip + clamp handled once for
144
+ * every chart instead of per call site (this used to be recomputed inside each chart's own `useChartTooltip`, and only for
145
+ * charts that remembered to use it).
146
+ *
147
+ * The box is measured after mount via `useLayoutEffect`, so the first paint of a given tooltip is
148
+ * hidden rather than misplaced — a tooltip that flashes at the wrong corner reads as a bug.
149
+ */
150
+ export function ChartTooltipFloat({
151
+ anchor,
152
+ children,
153
+ }: {
154
+ anchor: { x: number; y: number } | null
155
+ children: ReactNode
156
+ }) {
157
+ const ref = useRef<HTMLDivElement>(null)
158
+ const [box, setBox] = useState<{ width: number; height: number } | null>(null)
159
+
160
+ useLayoutEffect(() => {
161
+ const el = ref.current
162
+ if (el === null) return
163
+ const { offsetWidth, offsetHeight } = el
164
+ setBox((prev) =>
165
+ prev !== null && prev.width === offsetWidth && prev.height === offsetHeight
166
+ ? prev
167
+ : { width: offsetWidth, height: offsetHeight },
168
+ )
169
+ }, [children, anchor])
170
+
171
+ if (anchor === null) return null
172
+ // SSR guard: react-dom/server has no jsdom and does not support portals.
173
+ if (typeof document === 'undefined') return null
174
+
175
+ const width = box?.width ?? 0
176
+ const height = box?.height ?? 0
177
+ const vw = window.innerWidth
178
+ const vh = window.innerHeight
179
+
180
+ const flipsLeft = anchor.x + TOOLTIP_GAP + width + VIEWPORT_MARGIN > vw
181
+ const left = flipsLeft
182
+ ? Math.max(VIEWPORT_MARGIN, anchor.x - TOOLTIP_GAP - width)
183
+ : anchor.x + TOOLTIP_GAP
184
+ const top = Math.min(
185
+ Math.max(VIEWPORT_MARGIN, anchor.y - TOOLTIP_GAP),
186
+ Math.max(VIEWPORT_MARGIN, vh - height - VIEWPORT_MARGIN),
187
+ )
188
+
189
+ return createPortal(
190
+ <div
191
+ ref={ref}
192
+ role="tooltip"
193
+ aria-live="polite"
194
+ style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
195
+ >
196
+ {children}
197
+ </div>,
198
+ document.body,
199
+ )
200
+ }
@@ -1,23 +1,59 @@
1
- import type { PointerEventHandler } from 'react'
1
+ import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
2
2
 
3
- /** Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
4
- * sync. */
3
+ /**
4
+ * Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
5
+ * sync.
6
+ *
7
+ * With `onKeyDown` it also becomes the chart's keyboard affordance: the rect takes focus and
8
+ * ←/→ scrub the cursor, so a chart's values are reachable without a pointer.
9
+ */
5
10
  export function HoverOverlay({
6
11
  width,
7
12
  height,
8
13
  onMove,
9
14
  onLeave,
15
+ onKeyDown,
16
+ onBlur,
17
+ ariaLabel,
18
+ valueNow,
19
+ valueMax,
20
+ valueText,
10
21
  }: {
11
22
  width: number
12
23
  height: number
13
24
  onMove: PointerEventHandler<SVGRectElement>
14
25
  onLeave: PointerEventHandler<SVGRectElement>
26
+ /** Present = the overlay is focusable and scrubs on ←/→. */
27
+ onKeyDown?: KeyboardEventHandler<SVGRectElement>
28
+ onBlur?: FocusEventHandler<SVGRectElement>
29
+ ariaLabel?: string
30
+ /** Index of the focused point — announced as the slider position. */
31
+ valueNow?: number
32
+ /** Last index of the domain. */
33
+ valueMax?: number
34
+ /** Human-readable label for the focused point (the formatted x key). */
35
+ valueText?: string
15
36
  }) {
16
37
  return (
17
38
  <rect
18
39
  width={width}
19
40
  height={height}
20
41
  fill="transparent"
42
+ {...(onKeyDown !== undefined && {
43
+ tabIndex: 0,
44
+ // `slider`, not `application`: `application` would drop this node out of the screen
45
+ // reader's normal browse mode entirely, which is a far bigger hammer than an arrow-key
46
+ // scrub needs. A slider announces both the affordance and the focused point.
47
+ role: 'slider',
48
+ 'aria-label': ariaLabel ?? 'Chart data — use arrow keys to scrub',
49
+ 'aria-orientation': 'horizontal' as const,
50
+ 'aria-valuemin': 0,
51
+ ...(valueMax !== undefined && { 'aria-valuemax': valueMax }),
52
+ ...(valueNow !== undefined && { 'aria-valuenow': valueNow }),
53
+ ...(valueText !== undefined && { 'aria-valuetext': valueText }),
54
+ onKeyDown,
55
+ ...(onBlur !== undefined && { onBlur }),
56
+ })}
21
57
  // `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
22
58
  // phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
23
59
  // reaches this overlay to scrub the chart.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `XZoneRects` — the x-analog of `ZoneRects`. Same SSR harness as `ChartFrame.test.tsx`:
3
+ * `src/charts/**` is Mantine-free, so `renderToStaticMarkup` needs no provider wrapper.
4
+ */
5
+ import { scalePoint } from '@visx/scale'
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { XZoneRects } from './XZoneRects'
9
+ import type { XZoneSpec } from './XZoneRects'
10
+
11
+ const DOMAIN = ['a', 'b', 'c', 'd']
12
+ const xScale = scalePoint<string>({ domain: DOMAIN, range: [0, 100], padding: 0.3 })
13
+
14
+ function render(zones: XZoneSpec[]): string {
15
+ return renderToStaticMarkup(<XZoneRects zones={zones} height={50} xScale={xScale} />)
16
+ }
17
+
18
+ describe('XZoneRects', () => {
19
+ test('both bounds present -> rect spans center-to-center', () => {
20
+ const from = xScale('b') as number
21
+ const to = xScale('c') as number
22
+ const markup = render([{ from: 'b', to: 'c', fill: 'red' }])
23
+ expect(markup).toContain(`x="${from}"`)
24
+ expect(markup).toContain(`width="${to - from}"`)
25
+ expect(markup).toContain('height="50"')
26
+ expect(markup).toContain('fill="red"')
27
+ })
28
+
29
+ test('omitted from -> resolves to the plot left edge (0)', () => {
30
+ const to = xScale('b') as number
31
+ const [rangeStart] = xScale.range() as [number, number]
32
+ const markup = render([{ to: 'b', fill: 'red' }])
33
+ expect(markup).toContain(`x="${rangeStart}"`)
34
+ expect(markup).toContain(`width="${to - rangeStart}"`)
35
+ })
36
+
37
+ test('omitted to -> resolves to the plot right edge (xScale.range()[1])', () => {
38
+ const from = xScale('c') as number
39
+ const [, rangeEnd] = xScale.range() as [number, number]
40
+ const markup = render([{ from: 'c', fill: 'red' }])
41
+ expect(markup).toContain(`x="${from}"`)
42
+ expect(markup).toContain(`width="${rangeEnd - from}"`)
43
+ })
44
+
45
+ test('an unknown key skips the band entirely — no rect, no clamping to a plot edge', () => {
46
+ const markup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
47
+ expect(markup).not.toContain('<rect')
48
+ })
49
+
50
+ test('an inverted range (to before from) renders no rect', () => {
51
+ const markup = render([{ from: 'c', to: 'a', fill: 'red' }])
52
+ expect(markup).not.toContain('<rect')
53
+ })
54
+
55
+ test('a degenerate zero-width range (from === to) renders no rect', () => {
56
+ const markup = render([{ from: 'b', to: 'b', fill: 'red' }])
57
+ expect(markup).not.toContain('<rect')
58
+ })
59
+
60
+ test('an empty zones array renders no rects', () => {
61
+ expect(render([])).not.toContain('<rect')
62
+ })
63
+ })
@@ -0,0 +1,67 @@
1
+ import type { scalePoint } from '@visx/scale'
2
+
3
+ /**
4
+ * The point-scale instance type, derived from `@visx/scale`'s own `scalePoint` return type.
5
+ * Equivalent to d3's `ScalePoint<string>` but resolved through `@visx/scale` (an installed dep) so
6
+ * it needs no `@types/d3-scale` and stays inside the visx-only-in-charts boundary.
7
+ */
8
+ type PointScale = ReturnType<typeof scalePoint<string>>
9
+
10
+ export type XZoneSpec = {
11
+ /** Left edge — a domain key produced by the kind's `getX`. Omit for "start of the plot area". */
12
+ from?: string
13
+ /** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
14
+ to?: string
15
+ fill: string
16
+ }
17
+
18
+ /**
19
+ * Vertical zone backgrounds (x-range overlays — twilight bands, a shaded time window) — one
20
+ * <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
21
+ * `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
22
+ *
23
+ * An omitted `from` resolves to `0` (start of the plot area); an omitted `to` resolves to
24
+ * `xScale.range()[1]` (the plot's right edge). A present key resolves to `xScale(key)` — the
25
+ * POINT CENTER, so a band reads as "from this sample to that sample", not edge-to-edge.
26
+ *
27
+ * A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
28
+ * it is NEVER clamped to a plot edge. Clamping would silently paint a band across the whole
29
+ * chart for a typo'd or stale key, which is the exact class of quiet lie the repo's "nothing to
30
+ * draw is two different states" doctrine rejects: an unknown key must read as "not drawn", not
31
+ * as "the whole plot".
32
+ *
33
+ * A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
34
+ * also skipped — no negative-width rect.
35
+ */
36
+ export function XZoneRects({
37
+ zones,
38
+ height,
39
+ xScale,
40
+ }: {
41
+ zones: XZoneSpec[]
42
+ /** Plot-area height (yMax); rects span from 0 to this. */
43
+ height: number
44
+ xScale: PointScale
45
+ }) {
46
+ return (
47
+ <>
48
+ {zones.map((z, i) => {
49
+ const [rangeStart, rangeEnd] = xScale.range() as [number, number]
50
+ const xFrom = z.from === undefined ? rangeStart : xScale(z.from)
51
+ const xTo = z.to === undefined ? rangeEnd : xScale(z.to)
52
+ if (xFrom === undefined || xTo === undefined) return null
53
+ if (xTo <= xFrom) return null
54
+ return (
55
+ <rect
56
+ key={`xzone-${i}`}
57
+ x={xFrom}
58
+ y={0}
59
+ width={xTo - xFrom}
60
+ height={height}
61
+ fill={z.fill}
62
+ />
63
+ )
64
+ })}
65
+ </>
66
+ )
67
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { deriveLegend, deriveTooltipRows } from './series'
3
+ import type { SeriesStyle } from './series'
4
+
5
+ const baseSeries: SeriesStyle = {
6
+ key: 'low-cloud',
7
+ label: 'Low cloud',
8
+ color: '#000',
9
+ mark: 'line',
10
+ }
11
+
12
+ describe('deriveLegend note passthrough', () => {
13
+ test('carries note through when set', () => {
14
+ const [entry] = deriveLegend([{ ...baseSeries, note: '0% all night' }])
15
+ expect(entry?.note).toBe('0% all night')
16
+ })
17
+
18
+ test('omits the note key entirely when unset', () => {
19
+ const [entry] = deriveLegend([baseSeries])
20
+ expect(entry).not.toHaveProperty('note')
21
+ })
22
+ })
23
+
24
+ describe('deriveTooltipRows', () => {
25
+ type Row = { v: number }
26
+ const base = { color: '#000', mark: 'bar' as const, getValue: (d: Row) => d.v }
27
+ const fmt = (v: number) => String(v)
28
+
29
+ test('a `tooltip: false` series is drawn and legended but produces no row', () => {
30
+ const rows = deriveTooltipRows(
31
+ [
32
+ { ...base, key: 'shown', label: 'Shown' },
33
+ { ...base, key: 'quiet', label: 'Quiet', tooltip: false },
34
+ ],
35
+ { v: 1 },
36
+ fmt,
37
+ )
38
+ expect(rows.map((r) => r.key)).toEqual(['shown'])
39
+ expect(deriveLegend([{ ...base, key: 'quiet', label: 'Quiet', tooltip: false }])).toHaveLength(
40
+ 1,
41
+ )
42
+ })
43
+
44
+ test('a null value still skips its row', () => {
45
+ const rows = deriveTooltipRows(
46
+ [{ ...base, key: 'gap', label: 'Gap', getValue: () => null }],
47
+ { v: 1 },
48
+ fmt,
49
+ )
50
+ expect(rows).toHaveLength(0)
51
+ })
52
+
53
+ test('a per-series formatValue wins over the shared fallback', () => {
54
+ const rows = deriveTooltipRows(
55
+ [{ ...base, key: 'a', label: 'A', formatValue: (v) => `${v}%` }],
56
+ { v: 42 },
57
+ fmt,
58
+ )
59
+ expect(rows[0]?.value).toBe('42%')
60
+ })
61
+ })