basalt-ui 1.14.0 → 1.16.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 (176) 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 +279 -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/agent/templates/CLAUDE-block.md.tpl +9 -6
  9. package/configs/oxlint-plugin.js +152 -0
  10. package/configs/oxlint-plugin.test.ts +107 -0
  11. package/configs/oxlint.json +2 -0
  12. package/dist/charts/cursor/resolve.d.ts +33 -0
  13. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  14. package/dist/charts/cursor/resolve.js +61 -0
  15. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  16. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  17. package/dist/charts/cursor/scope.d.ts +17 -0
  18. package/dist/charts/cursor/scope.d.ts.map +1 -0
  19. package/dist/charts/cursor/scope.js +20 -0
  20. package/dist/charts/cursor/store.d.ts +31 -0
  21. package/dist/charts/cursor/store.d.ts.map +1 -0
  22. package/dist/charts/cursor/store.js +25 -0
  23. package/dist/charts/cursor/store.test.d.ts +2 -0
  24. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  26. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  27. package/dist/charts/hooks/useChartCursor.js +104 -0
  28. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  29. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  30. package/dist/charts/index.d.ts +16 -13
  31. package/dist/charts/index.d.ts.map +1 -1
  32. package/dist/charts/index.js +38 -19
  33. package/dist/charts/kinds/Bars.d.ts +45 -54
  34. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  35. package/dist/charts/kinds/Bars.js +235 -392
  36. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  37. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  38. package/dist/charts/kinds/Donut.d.ts +5 -4
  39. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  40. package/dist/charts/kinds/Donut.js +17 -14
  41. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  42. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  43. package/dist/charts/kinds/DualPanel.js +185 -92
  44. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  45. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  46. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  47. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  48. package/dist/charts/kinds/Heatmap.js +38 -22
  49. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  50. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/MultiLine.js +122 -235
  52. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  53. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  54. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  55. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  56. package/dist/charts/kinds/StackedArea.js +74 -132
  57. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  58. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  59. package/dist/charts/kinds/ZonedLine.js +104 -184
  60. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  61. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  62. package/dist/charts/layout/auto-margin.d.ts +60 -0
  63. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  64. package/dist/charts/layout/auto-margin.js +37 -0
  65. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  66. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  67. package/dist/charts/primitives/CartesianChart.d.ts +161 -0
  68. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  69. package/dist/charts/primitives/CartesianChart.js +359 -0
  70. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  71. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  72. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  73. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  74. package/dist/charts/primitives/ChartFrame.js +17 -2
  75. package/dist/charts/primitives/ChartLegend.d.ts +15 -1
  76. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  77. package/dist/charts/primitives/ChartLegend.js +25 -3
  78. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  79. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  80. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  81. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  82. package/dist/charts/primitives/ChartTooltip.js +45 -19
  83. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  84. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  85. package/dist/charts/primitives/HoverOverlay.js +22 -1
  86. package/dist/charts/primitives/XZoneRects.d.ts +54 -0
  87. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  88. package/dist/charts/primitives/XZoneRects.js +65 -0
  89. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  90. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  91. package/dist/charts/series.d.ts +28 -3
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/series.js +5 -2
  94. package/dist/charts/series.test.d.ts +2 -0
  95. package/dist/charts/series.test.d.ts.map +1 -0
  96. package/dist/charts/utils/measure-text.d.ts +25 -0
  97. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  98. package/dist/charts/utils/measure-text.js +64 -0
  99. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  100. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts.map +1 -1
  102. package/dist/surfaces.d.ts +1 -1
  103. package/dist/surfaces.d.ts.map +1 -1
  104. package/dist/surfaces.js +1 -1
  105. package/dist/tokens/chart-margin.test.d.ts +2 -0
  106. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  107. package/dist/tokens/index.d.ts +29 -0
  108. package/dist/tokens/index.d.ts.map +1 -1
  109. package/dist/tokens/index.js +10 -0
  110. package/dist/tokens/palette.d.ts +4 -0
  111. package/dist/tokens/palette.d.ts.map +1 -1
  112. package/dist/tokens/palette.js +3 -0
  113. package/llms.txt +3 -3
  114. package/package.json +1 -1
  115. package/src/charts/cursor/resolve.test.ts +61 -0
  116. package/src/charts/cursor/resolve.ts +102 -0
  117. package/src/charts/cursor/scope.tsx +32 -0
  118. package/src/charts/cursor/store.test.ts +46 -0
  119. package/src/charts/cursor/store.ts +53 -0
  120. package/src/charts/hooks/useChartCursor.ts +163 -0
  121. package/src/charts/hooks/useChartSize.ts +8 -4
  122. package/src/charts/index.ts +35 -14
  123. package/src/charts/kinds/Bars.test.tsx +98 -0
  124. package/src/charts/kinds/Bars.tsx +350 -467
  125. package/src/charts/kinds/Donut.tsx +31 -23
  126. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  127. package/src/charts/kinds/DualPanel.tsx +301 -135
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  130. package/src/charts/kinds/MultiLine.tsx +171 -290
  131. package/src/charts/kinds/StackedArea.tsx +116 -175
  132. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  133. package/src/charts/kinds/ZonedLine.tsx +149 -255
  134. package/src/charts/layout/auto-margin.test.ts +42 -0
  135. package/src/charts/layout/auto-margin.ts +110 -0
  136. package/src/charts/primitives/CartesianChart.test.tsx +160 -0
  137. package/src/charts/primitives/CartesianChart.tsx +598 -0
  138. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  139. package/src/charts/primitives/ChartFrame.tsx +40 -4
  140. package/src/charts/primitives/ChartLegend.test.tsx +78 -0
  141. package/src/charts/primitives/ChartLegend.tsx +52 -2
  142. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  143. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  144. package/src/charts/primitives/XZoneRects.test.tsx +123 -0
  145. package/src/charts/primitives/XZoneRects.tsx +132 -0
  146. package/src/charts/series.test.ts +112 -0
  147. package/src/charts/series.ts +33 -5
  148. package/src/charts/utils/measure-text.test.ts +36 -0
  149. package/src/charts/utils/measure-text.ts +109 -0
  150. package/src/content/fence-block.tsx +2 -3
  151. package/src/surfaces.ts +2 -1
  152. package/src/theme/spacing.test.ts +2 -0
  153. package/src/tokens/chart-margin.test.ts +39 -0
  154. package/src/tokens/density.test.ts +1 -0
  155. package/src/tokens/index.ts +36 -0
  156. package/src/tokens/palette.ts +3 -0
  157. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  158. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  159. package/dist/charts/hooks/useChartTooltip.js +0 -26
  160. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  161. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  162. package/dist/charts/hooks/useHoverSync.js +0 -75
  163. package/dist/charts/hover-context.d.ts +0 -21
  164. package/dist/charts/hover-context.d.ts.map +0 -1
  165. package/dist/charts/hover-context.js +0 -12
  166. package/dist/charts/hover-sync.d.ts +0 -14
  167. package/dist/charts/hover-sync.d.ts.map +0 -1
  168. package/dist/charts/hover-sync.js +0 -17
  169. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  170. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  171. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  172. package/src/charts/hooks/useChartTooltip.ts +0 -31
  173. package/src/charts/hooks/useHoverSync.ts +0 -152
  174. package/src/charts/hover-context.ts +0 -28
  175. package/src/charts/hover-sync.tsx +0 -37
  176. 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,78 @@
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
+ })
49
+
50
+ describe('ChartLegend strokeOpacity — the swatch cannot lie about a dimmed line', () => {
51
+ test('a line swatch honors a fractional strokeOpacity', () => {
52
+ const markup = render([{ ...BASE, shape: 'line', strokeOpacity: 0.4 }])
53
+ expect(markup).toContain('stroke-opacity="0.4"')
54
+ })
55
+
56
+ test('defaults to full opacity when unset', () => {
57
+ const markup = render([{ ...BASE, shape: 'line' }])
58
+ expect(markup).toContain('stroke-opacity="1"')
59
+ })
60
+
61
+ test('a folded MA-companion child swatch honors it too', () => {
62
+ const markup = render([
63
+ {
64
+ ...BASE,
65
+ children: [
66
+ {
67
+ key: 'ma',
68
+ label: '7d MA',
69
+ color: 'var(--vx-fill-2)',
70
+ shape: 'line',
71
+ strokeOpacity: 0.4,
72
+ },
73
+ ],
74
+ },
75
+ ])
76
+ expect(markup).toContain('stroke-opacity="0.4"')
77
+ })
78
+ })
@@ -13,11 +13,17 @@ export type LegendEntry = {
13
13
  dashed?: boolean
14
14
  /** bar swatch opacity — honored instead of a hardcoded value, so it cannot lie about the fill. */
15
15
  fillOpacity?: number
16
+ /** line/splitLine swatch stroke opacity — parity with `fillOpacity` above, so a dimmed
17
+ * companion line's swatch cannot lie about its own opacity either. */
18
+ strokeOpacity?: number
16
19
  /** Drives `groups` rendering (series → hairline divider → overlay → reference). */
17
20
  role?: SeriesRole
18
21
  /** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
19
22
  * subordinate sub-entries beside the parent's label instead of vanishing. */
20
23
  children?: LegendEntry[]
24
+ /** Short qualifier rendered after the label in muted text — e.g. a flat-at-zero series that is
25
+ * invisible in the plot. */
26
+ note?: string
21
27
  }
22
28
 
23
29
  const LEGEND_ITEM_BASE: CSSProperties = {
@@ -44,6 +50,9 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
44
50
 
45
51
  const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
46
52
 
53
+ /** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
54
+ const NO_HIDDEN: ReadonlySet<string> = new Set()
55
+
47
56
  const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
48
57
 
49
58
  /** Order entries series → overlay → reference and record where a divider belongs. */
@@ -107,6 +116,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
107
116
  y2={7}
108
117
  stroke={item.color}
109
118
  strokeWidth={item.strokeWidth ?? 2.5}
119
+ strokeOpacity={item.strokeOpacity ?? 1}
110
120
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
111
121
  />
112
122
  <line
@@ -116,6 +126,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
116
126
  y2={7}
117
127
  stroke={item.secondColor}
118
128
  strokeWidth={item.strokeWidth ?? 2.5}
129
+ strokeOpacity={item.strokeOpacity ?? 1}
119
130
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
120
131
  />
121
132
  </svg>
@@ -167,6 +178,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
167
178
  y2={3}
168
179
  stroke={item.color}
169
180
  strokeWidth={item.strokeWidth ?? 3}
181
+ strokeOpacity={item.strokeOpacity ?? 1}
170
182
  strokeLinecap="round"
171
183
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
172
184
  />
@@ -174,6 +186,13 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
174
186
  )
175
187
  }
176
188
 
189
+ // Same dimming as `LEGEND_CHILD_STYLE` below — the legend wrapper already sets the base text color
190
+ // to `VX.muted` (`wrapperStyle`), so subordinate text is just that color at reduced opacity, never
191
+ // a second color token.
192
+ const LEGEND_NOTE_STYLE: CSSProperties = {
193
+ opacity: 0.75,
194
+ }
195
+
177
196
  const LEGEND_CHILD_STYLE: CSSProperties = {
178
197
  display: 'flex',
179
198
  alignItems: 'center',
@@ -209,6 +228,7 @@ function LegendChildSwatch({ item }: { item: LegendEntry }) {
209
228
  y2={5}
210
229
  stroke={item.color}
211
230
  strokeWidth={item.strokeWidth ?? 2}
231
+ strokeOpacity={item.strokeOpacity ?? 1}
212
232
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
213
233
  />
214
234
  </svg>
@@ -221,6 +241,9 @@ function LegendChild({ item }: { item: LegendEntry }) {
221
241
  <span style={LEGEND_CHILD_STYLE}>
222
242
  <LegendChildSwatch item={item} />
223
243
  <span>{item.label}</span>
244
+ {/* No LEGEND_NOTE_STYLE here — LEGEND_CHILD_STYLE already dims the whole child block, and
245
+ compounding the two opacities would push a folded companion's note near-invisible. */}
246
+ {item.note ? <span>{item.note}</span> : null}
224
247
  </span>
225
248
  )
226
249
  }
@@ -232,11 +255,17 @@ function LegendChild({ item }: { item: LegendEntry }) {
232
255
  * reference bands instead of one flat smear. `maxRows` caps the number of rendered entries and
233
256
  * rolls the remainder into a `+N more` chip (row-accurate wrap measurement is `ChartFrame`'s job —
234
257
  * this is an entry-count cap, a deliberate simplification until that primitive lands).
258
+ *
259
+ * `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
260
+ * and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
261
+ * §5). Omit it for a static legend; a hidden entry is announced via `aria-pressed`.
235
262
  */
236
263
  export function ChartLegend({
237
264
  items,
238
265
  highlighted = null,
239
266
  onHighlight,
267
+ hidden = NO_HIDDEN,
268
+ onToggle,
240
269
  chartId = '',
241
270
  placement = 'bottom',
242
271
  groups = false,
@@ -245,6 +274,10 @@ export function ChartLegend({
245
274
  items: LegendEntry[]
246
275
  highlighted?: string | null
247
276
  onHighlight?: (key: string | null) => void
277
+ /** Keys currently toggled off. */
278
+ hidden?: ReadonlySet<string>
279
+ /** Present = entries are clickable toggles. */
280
+ onToggle?: (key: string) => void
248
281
  chartId?: string
249
282
  placement?: LegendPlacement
250
283
  groups?: boolean
@@ -255,6 +288,10 @@ export function ChartLegend({
255
288
  if (key !== undefined) onHighlight?.(key)
256
289
  }
257
290
  const handleLeave = () => onHighlight?.(null)
291
+ const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
292
+ const key = e.currentTarget.dataset['legendKey']
293
+ if (key !== undefined) onToggle?.(key)
294
+ }
258
295
 
259
296
  const idPrefix = chartId ? `${chartId}-` : ''
260
297
  const vertical = placement === 'left' || placement === 'right'
@@ -270,11 +307,23 @@ export function ChartLegend({
270
307
  key={item.key}
271
308
  type="button"
272
309
  data-legend-key={item.key}
273
- aria-label={item.label}
310
+ // The note is the whole point for a series that is invisible in the plot, so it has to
311
+ // reach a screen reader too — an explicit aria-label would otherwise replace it.
312
+ aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
313
+ {...(onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
274
314
  style={{
275
315
  ...LEGEND_ITEM_BUTTON,
276
- opacity: highlighted === null || highlighted === item.key ? 1 : 0.3,
316
+ cursor: onToggle === undefined ? 'default' : 'pointer',
317
+ // Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
318
+ // it is the one being hovered.
319
+ opacity: hidden.has(item.key)
320
+ ? 0.35
321
+ : highlighted === null || highlighted === item.key
322
+ ? 1
323
+ : 0.3,
324
+ textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
277
325
  }}
326
+ {...(onToggle !== undefined && { onClick: handleToggle })}
278
327
  onMouseEnter={handleEnter}
279
328
  onMouseLeave={handleLeave}
280
329
  onFocus={handleEnter}
@@ -282,6 +331,7 @@ export function ChartLegend({
282
331
  >
283
332
  <LegendSwatch item={item} idPrefix={idPrefix} />
284
333
  <span>{item.label}</span>
334
+ {item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
285
335
  {item.children?.map((child) => (
286
336
  <LegendChild key={child.key} item={child} />
287
337
  ))}
@@ -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,123 @@
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
+
64
+ // Mirrors XZoneRects' own bound resolution exactly (same clamp + sign math), so expectations
65
+ // never drift from the implementation's floating-point arithmetic.
66
+ function edgeBound(key: string, sign: 1 | -1): number {
67
+ const step = xScale.step()
68
+ const [rangeStart, rangeEnd] = xScale.range() as [number, number]
69
+ const rangeMin = Math.min(rangeStart, rangeEnd)
70
+ const rangeMax = Math.max(rangeStart, rangeEnd)
71
+ const center = xScale(key) as number
72
+ return Math.min(Math.max(center + sign * (step / 2), rangeMin), rangeMax)
73
+ }
74
+
75
+ test('align: "edge" widens a two-key band by exactly one step over center mode', () => {
76
+ const step = xScale.step()
77
+ const centerFrom = xScale('b') as number
78
+ const centerTo = xScale('c') as number
79
+ const centerMarkup = render([{ from: 'b', to: 'c', fill: 'red' }])
80
+ const edgeMarkup = render([{ from: 'b', to: 'c', fill: 'red', align: 'edge' }])
81
+ expect(centerMarkup).toContain(`width="${centerTo - centerFrom}"`)
82
+ const edgeFrom = edgeBound('b', -1)
83
+ const edgeTo = edgeBound('c', 1)
84
+ expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
85
+ expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
86
+ expect(edgeTo - edgeFrom).toBeCloseTo(centerTo - centerFrom + step, 10)
87
+ })
88
+
89
+ test('align: "edge" with from === to renders exactly one step wide, where center renders nothing', () => {
90
+ const step = xScale.step()
91
+ const centerMarkup = render([{ from: 'b', to: 'b', fill: 'red' }])
92
+ const edgeMarkup = render([{ from: 'b', to: 'b', fill: 'red', align: 'edge' }])
93
+ expect(centerMarkup).not.toContain('<rect')
94
+ const edgeFrom = edgeBound('b', -1)
95
+ const edgeTo = edgeBound('b', 1)
96
+ expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
97
+ expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
98
+ expect(edgeTo - edgeFrom).toBeCloseTo(step, 10)
99
+ })
100
+
101
+ test('an unknown key is skipped in both align modes', () => {
102
+ const centerMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
103
+ const edgeMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red', align: 'edge' }])
104
+ expect(centerMarkup).not.toContain('<rect')
105
+ expect(edgeMarkup).not.toContain('<rect')
106
+ })
107
+
108
+ test('align: "edge" clamps the widened left edge at the first sample into the plot range', () => {
109
+ const [rangeStart] = xScale.range() as [number, number]
110
+ const markup = render([{ from: 'a', to: 'a', fill: 'red', align: 'edge' }])
111
+ const edgeTo = edgeBound('a', 1)
112
+ expect(markup).toContain(`x="${rangeStart}"`)
113
+ expect(markup).toContain(`width="${edgeTo - rangeStart}"`)
114
+ })
115
+
116
+ test('align: "edge" clamps the widened right edge at the last sample into the plot range', () => {
117
+ const [, rangeEnd] = xScale.range() as [number, number]
118
+ const markup = render([{ from: 'd', to: 'd', fill: 'red', align: 'edge' }])
119
+ const edgeFrom = edgeBound('d', -1)
120
+ expect(markup).toContain(`x="${edgeFrom}"`)
121
+ expect(markup).toContain(`width="${rangeEnd - edgeFrom}"`)
122
+ })
123
+ })