basalt-ui 1.30.0 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/MIGRATING.md +240 -30
  3. package/dist/charts/index.d.ts +1 -1
  4. package/dist/charts/index.d.ts.map +1 -1
  5. package/dist/charts/kinds/BandStrip.d.ts +5 -3
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/Bars.d.ts +4 -2
  8. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  9. package/dist/charts/kinds/Donut.d.ts +4 -2
  10. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  11. package/dist/charts/kinds/DualPanel.d.ts +4 -2
  12. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  13. package/dist/charts/kinds/Heatmap.d.ts +5 -2
  14. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  15. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  16. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  17. package/dist/charts/kinds/MultiLine.d.ts +4 -2
  18. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  19. package/dist/charts/kinds/StackedArea.d.ts +4 -2
  20. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  21. package/dist/charts/kinds/ZonedLine.d.ts +4 -2
  22. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  23. package/dist/charts/primitives/CartesianChart.d.ts +4 -1
  24. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartFrame.d.ts +9 -8
  26. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  27. package/dist/charts/primitives/ChartFrame.js +21 -14
  28. package/dist/charts/primitives/chart-frame-layout.d.ts +63 -9
  29. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  30. package/dist/charts/primitives/chart-frame-layout.js +30 -6
  31. package/llms.txt +2 -2
  32. package/package.json +1 -1
  33. package/src/charts/index.ts +1 -0
  34. package/src/charts/kinds/BandStrip.tsx +5 -3
  35. package/src/charts/kinds/Bars.tsx +4 -2
  36. package/src/charts/kinds/Donut.tsx +4 -2
  37. package/src/charts/kinds/DualPanel.tsx +4 -2
  38. package/src/charts/kinds/Heatmap.tsx +5 -2
  39. package/src/charts/kinds/MirroredBars.tsx +4 -2
  40. package/src/charts/kinds/MultiLine.tsx +4 -2
  41. package/src/charts/kinds/StackedArea.tsx +4 -2
  42. package/src/charts/kinds/ZonedLine.tsx +4 -2
  43. package/src/charts/primitives/CartesianChart.tsx +4 -1
  44. package/src/charts/primitives/ChartFrame.tsx +33 -34
  45. package/src/charts/primitives/chart-frame-layout.ts +89 -16
@@ -6,6 +6,7 @@ import type { BasaltProps } from '../../common/props'
6
6
  import type { CursorResolution } from '../cursor/resolve'
7
7
  import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
8
8
  import { CartesianChart } from '../primitives/CartesianChart'
9
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
9
10
  import type { XZoneSpec } from '../primitives/XZoneRects'
10
11
  import type { ZoneSpec } from '../primitives/ZoneRects'
11
12
  import type { ChartState } from '../primitives/ChartPending'
@@ -15,8 +16,9 @@ import { VX } from '../../tokens'
15
16
 
16
17
  export type MultiLineProps<T> = BasaltProps & {
17
18
  data: T[]
18
- /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
19
- height?: number
19
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
20
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
21
+ height?: number | ResponsiveChartHeight
20
22
  chartId: string
21
23
  /** Extracts the x-axis category (date string) from a data point. */
22
24
  getX: (d: T) => string
@@ -5,6 +5,7 @@ import type { BasaltProps } from '../../common/props'
5
5
  import type { CursorResolution } from '../cursor/resolve'
6
6
  import { CartesianChart } from '../primitives/CartesianChart'
7
7
  import type { AxisConfig, PlotContext } from '../primitives/CartesianChart'
8
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
8
9
  import type { ChartState } from '../primitives/ChartPending'
9
10
  import { curveFor } from '../series'
10
11
  import type { ChartLegendConfig, ChartSeries } from '../series'
@@ -12,8 +13,9 @@ import { isDev } from '../../common/is-dev'
12
13
 
13
14
  export type StackedAreaProps<T> = BasaltProps & {
14
15
  data: T[]
15
- /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
16
- height?: number
16
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
17
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
18
+ height?: number | ResponsiveChartHeight
17
19
  chartId: string
18
20
  getX: (d: T) => string
19
21
  /** The stacked bands, bottom to top — the single source of truth for color, legend, and tooltip
@@ -7,6 +7,7 @@ import type { CursorResolution } from '../cursor/resolve'
7
7
  import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
8
8
  import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
9
9
  import { CartesianChart } from '../primitives/CartesianChart'
10
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
10
11
  import type { XZoneSpec } from '../primitives/XZoneRects'
11
12
  import type { ZoneSpec } from '../primitives/ZoneRects'
12
13
  import type { ChartState } from '../primitives/ChartPending'
@@ -31,8 +32,9 @@ export type ZonedLineRefLine = {
31
32
 
32
33
  export type ZonedLineProps<T> = BasaltProps & {
33
34
  data: T[]
34
- /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
35
- height?: number
35
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
36
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
37
+ height?: number | ResponsiveChartHeight
36
38
  chartId: string
37
39
  /** Extracts the x-axis category (date string) from a data point. */
38
40
  getX: (d: T) => string
@@ -17,6 +17,7 @@ import { fmtAxisDate } from '../utils/format'
17
17
  import { autoXLabelRotate, smartTicks, smartTicksEvery, xLabelPxFor } from '../utils/ticks'
18
18
  import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
19
19
  import { ChartFrame, resolveLegend } from './ChartFrame'
20
+ import type { ResponsiveChartHeight } from './ChartFrame'
20
21
  import { useChartTierMetrics } from './chart-tier'
21
22
  import type { ChartState } from './ChartPending'
22
23
  import { ChartTooltipFloat, TooltipBody, TooltipHeader, TooltipRow } from './ChartTooltip'
@@ -209,7 +210,9 @@ export type CartesianChartProps<T> = BasaltProps & {
209
210
  /** X-range bands (time windows), drawn behind the marks. */
210
211
  xZones?: XZoneSpec[]
211
212
  refLines?: { value: number; color: string; dashed?: boolean; axis?: 'left' | 'right' }[]
212
- height?: number
213
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
214
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
215
+ height?: number | ResponsiveChartHeight
213
216
  aspectRatio?: number
214
217
  fill?: boolean
215
218
  legend?: ChartLegendConfig | false
@@ -6,10 +6,9 @@ import { useChartSize } from '../hooks/useChartSize'
6
6
  import { deriveLegend } from '../series'
7
7
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
8
8
  import {
9
- legendEntryCap,
10
9
  resolveChartTier,
11
10
  resolveFrameHeight,
12
- resolveLegendMaxRows,
11
+ resolveLegendRollup,
13
12
  resolvePlotRect,
14
13
  } from './chart-frame-layout'
15
14
  import type { ResponsiveChartHeight } from './chart-frame-layout'
@@ -20,7 +19,12 @@ import type { ChartState } from './ChartPending'
20
19
 
21
20
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
22
21
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
23
- export { legendEntryCap, resolvePlotRect, resolveChartTier } from './chart-frame-layout'
22
+ export {
23
+ legendEntryCap,
24
+ resolveLegendRollup,
25
+ resolvePlotRect,
26
+ resolveChartTier,
27
+ } from './chart-frame-layout'
24
28
  export type { ResponsiveChartHeight } from './chart-frame-layout'
25
29
 
26
30
  const DEFAULT_HEIGHT = 240
@@ -34,13 +38,14 @@ export type ChartFrameLegend = {
34
38
  /**
35
39
  * Wrap cap → "+N more" rollup at high cardinality.
36
40
  *
37
- * An explicit value WINS OUTRIGHT — including over the phone tier's own two-row default
38
- * (`chartTierMetrics`). It used to be `Math.min`'d against the tier, which made the documented
39
- * per-chart opt-out ("opt back out with an explicit `legend.maxRows`") false in the one direction
40
- * anybody needs it: a consumer's 380px inspector panel is "a phone" on a 1440px desktop, and a
41
- * six-series legend rolled up to `+4 more` with no way to say no. Under `fill` the measured
42
- * entry cap can still roll up FURTHER — that is a floor on the plot, not a tier default
43
- * ({@link legendEntryCap}).
41
+ * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
42
+ * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
43
+ * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
44
+ * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
45
+ * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
46
+ * number a caller stated, so under `fill` the PLOT yields the height instead
47
+ * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
48
+ * gets what is left — visible, and yours.
44
49
  */
45
50
  maxRows?: number
46
51
  /** Visually separate role: series | overlay | reference. */
@@ -232,38 +237,32 @@ export function ChartFrame({
232
237
  const sideLegendWidth = legendVisible && vertical ? legendW : 0
233
238
  const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
234
239
 
240
+ const legendItems = legend === false ? [] : deriveLegend(series)
241
+ const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
242
+
243
+ // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
244
+ // cases and why the composition, not either half, is what shipped broken). A fixed-height frame
245
+ // grows around its legend; a `fill` one cannot, so its legend rolls up to the measured fit
246
+ // instead of eating the plot — unless the caller stated a number, which nothing trims and the
247
+ // plot pays for. Top/bottom only: a side legend costs width, not height.
248
+ const { maxRows, legendWins } = resolveLegendRollup({
249
+ ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
250
+ tier,
251
+ fillBand: legendVisible && fill && !vertical,
252
+ items: legendItems,
253
+ containerW,
254
+ available: resolvedHeight - VX.minPlotHeight,
255
+ })
256
+
235
257
  const plot = resolvePlotRect({
236
258
  containerW,
237
259
  resolvedHeight,
238
260
  minWidth,
239
261
  sideLegendWidth,
240
262
  topBottomLegendHeight,
263
+ ...(legendWins && { legendWins }),
241
264
  })
242
265
 
243
- const legendItems = legend === false ? [] : deriveLegend(series)
244
- const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
245
-
246
- // A fixed-height frame grows around its legend; a `fill` one cannot, so its legend rolls up
247
- // instead of eating the plot (see `legendEntryCap`). Top/bottom only — a side legend costs
248
- // width, not height.
249
- // The resolved cap — the caller's if they named one, else the tier's DEFAULT, never both
250
- // `Math.min`'d (`resolveLegendMaxRows` owns that law and says why) — enters the fill measurement
251
- // as the upper bound, so a phone-width `fill` frame can only ever roll up further, never less.
252
- const callerMaxRows =
253
- legend === false ? undefined : resolveLegendMaxRows({ callerMaxRows: legend.maxRows, tier })
254
-
255
- const maxRows =
256
- legend === false
257
- ? undefined
258
- : fill && !vertical
259
- ? legendEntryCap({
260
- items: legendItems,
261
- containerW,
262
- available: resolvedHeight - VX.minPlotHeight,
263
- ...(callerMaxRows !== undefined && { callerMaxRows }),
264
- })
265
- : callerMaxRows
266
-
267
266
  const legendNode =
268
267
  legend === false || resolvedState !== null ? null : (
269
268
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
@@ -137,7 +137,7 @@ export type ChartTierMetrics = {
137
137
  /** Floating tooltip `minWidth`, px. */
138
138
  tooltipMinWidth: number
139
139
  /** Default entry cap on the legend, or `undefined` for no default cap. An explicit
140
- * `legend.maxRows` still wins as the upper bound. */
140
+ * `legend.maxRows` replaces it outright ({@link resolveLegendMaxRows}). */
141
141
  legendMaxRows: number | undefined
142
142
  /** Per-side margin FLOORS (`VX.margin` is a floor, never a ceiling — §1). */
143
143
  margin: ChartMargin
@@ -186,9 +186,14 @@ export function chartTierMetrics(tier: ChartTier): ChartTierMetrics {
186
186
  * panel on a 1440px desktop is "a phone", so a six-series legend rolled up to two rows + `+4 more`
187
187
  * with no way to say no. `margin` and `xLabelRotate` always had that escape; the legend did not.
188
188
  *
189
- * A `fill` frame's measured {@link legendEntryCap} still applies ON TOP of whatever wins here — it
190
- * is a floor on the PLOT, not a tier default, so a chart that would otherwise render no plot at all
191
- * still rolls up.
189
+ * The measured {@link legendEntryCap} a `fill` frame applies is NOT a second ceiling on top of
190
+ * this: it resolves an ABSENT cap only. 1.30.0 ran it over the caller's number as well
191
+ * (`Math.min(fitted, caller)`), which made "wins outright" false again in the one place it was
192
+ * needed — meteo measured a 7-entry legend pinned to 2 entries at `maxRows` 3, 6 AND 99, with 5
193
+ * series drawn in colours the legend refused to name. The fit is not physics either: it derives
194
+ * from `VX.minPlotHeight`, which is a framework DEFAULT about the plot. Two defaults do not
195
+ * outvote the one number a caller stated, so the PLOT yields the height instead
196
+ * ({@link resolvePlotRect}'s `legendWins`) — visibly, in the frame the caller sized.
192
197
  */
193
198
  export function resolveLegendMaxRows(input: {
194
199
  callerMaxRows?: number | undefined
@@ -197,6 +202,48 @@ export function resolveLegendMaxRows(input: {
197
202
  return input.callerMaxRows ?? chartTierMetrics(input.tier).legendMaxRows
198
203
  }
199
204
 
205
+ /**
206
+ * The whole legend-cap decision in one place: which cap `ChartLegend` gets, and whether the plot
207
+ * pays for it.
208
+ *
209
+ * It lives here rather than inline in `ChartFrame` because the COMPOSITION is what broke. 1.30.0
210
+ * had both halves right on their own — {@link resolveLegendMaxRows} honoured a stated cap,
211
+ * {@link legendEntryCap} measured a fit — and then ran the second over the first, which no test
212
+ * could see because neither pure function was wrong and the frame that combined them needs a
213
+ * `ResizeObserver` to test. Now the combination is a pure function too.
214
+ *
215
+ * Three cases, in order:
216
+ * 1. The caller STATED a number → it is the cap, and on a `fill` band the plot yields
217
+ * (`legendWins`). Nothing measured or defaulted trims it.
218
+ * 2. No `fill` band → the tier's default (or none), because the frame simply grows.
219
+ * 3. A `fill` band with nothing stated → the measured fit, bounded by the tier's default.
220
+ */
221
+ export function resolveLegendRollup(input: {
222
+ /** The caller's own `legend.maxRows`. */
223
+ statedMaxRows?: number | undefined
224
+ tier: ChartTier
225
+ /** A visible top/bottom legend on a `fill` frame — the one shape whose box cannot grow. */
226
+ fillBand: boolean
227
+ items: readonly LegendEntry[]
228
+ containerW: number
229
+ /** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
230
+ available: number
231
+ }): { maxRows: number | undefined; legendWins: boolean } {
232
+ const { statedMaxRows, tier, fillBand, items, containerW, available } = input
233
+ if (statedMaxRows !== undefined) return { maxRows: statedMaxRows, legendWins: fillBand }
234
+ const tierMaxRows = resolveLegendMaxRows({ callerMaxRows: undefined, tier })
235
+ if (!fillBand) return { maxRows: tierMaxRows, legendWins: false }
236
+ return {
237
+ maxRows: legendEntryCap({
238
+ items,
239
+ containerW,
240
+ available,
241
+ ...(tierMaxRows !== undefined && { defaultMaxRows: tierMaxRows }),
242
+ }),
243
+ legendWins: false,
244
+ }
245
+ }
246
+
200
247
  /**
201
248
  * Resolve the plot rect from the measured box and the measured legend band. Pure and exported so
202
249
  * the two floors below are testable without a `ResizeObserver`.
@@ -211,9 +258,11 @@ export function resolveLegendMaxRows(input: {
211
258
  * `height={240}` toward zero and the body stopped rendering entirely. The plot stops at
212
259
  * `VX.minPlotHeight` and the frame's own box grows by the difference instead — it is a flex
213
260
  * column with `height: auto`, so that growth is automatic. Under `fill` the box CANNOT grow, so
214
- * the legend is capped instead ({@link legendEntryCap}). An unmeasured box (`resolvedHeight <=
215
- * 0`, i.e. a `fill` frame before its first measurement) stays at 0 and renders nothing, exactly
216
- * as before — the floor must never invent a height for a box nobody has measured.
261
+ * the legend is capped instead ({@link legendEntryCap}) — UNLESS the caller stated
262
+ * `legend.maxRows`, which is `legendWins`: there the floor itself yields, because a floor is a
263
+ * default and the caller's number is not. An unmeasured box (`resolvedHeight <= 0`, i.e. a
264
+ * `fill` frame before its first measurement) stays at 0 and renders nothing, exactly as before —
265
+ * the floor must never invent a height for a box nobody has measured.
217
266
  */
218
267
  export function resolvePlotRect(input: {
219
268
  containerW: number
@@ -221,12 +270,28 @@ export function resolvePlotRect(input: {
221
270
  minWidth: number
222
271
  sideLegendWidth: number
223
272
  topBottomLegendHeight: number
273
+ /**
274
+ * The caller stated `legend.maxRows` on a `fill` frame, so the legend takes the rows it asked
275
+ * for and the plot takes what is left — `VX.minPlotHeight` stops being a floor. Without this the
276
+ * honoured cap would push the frame's content PAST its own cell (the plot holds 120px, the legend
277
+ * band adds its own) and paint over whatever sits below it; with it, the cost of the caller's
278
+ * number lands where they can see it. Spend the whole box on legend rows and there is no plot
279
+ * left to draw — that is their arithmetic, made visible rather than silently rolled up.
280
+ */
281
+ legendWins?: boolean
224
282
  }): { width: number; height: number } {
225
- const { containerW, resolvedHeight, minWidth, sideLegendWidth, topBottomLegendHeight } = input
283
+ const {
284
+ containerW,
285
+ resolvedHeight,
286
+ minWidth,
287
+ sideLegendWidth,
288
+ topBottomLegendHeight,
289
+ legendWins = false,
290
+ } = input
291
+ const plotFloor = legendWins ? 0 : VX.minPlotHeight
226
292
  return {
227
293
  width: containerW === 0 ? minWidth : Math.max(containerW - sideLegendWidth, 1),
228
- height:
229
- resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, VX.minPlotHeight),
294
+ height: resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, plotFloor),
230
295
  }
231
296
  }
232
297
 
@@ -272,17 +337,25 @@ function entriesWithinRows(items: readonly LegendEntry[], width: number, rows: n
272
337
  * MEASURING the labels, not from assuming one entry per row — otherwise a five-entry legend that
273
338
  * fits on one line would roll up to `+2 more` in every 240px cell, moving rendering for charts
274
339
  * that never had the bug. Returns `undefined` when the whole legend already fits.
340
+ *
341
+ * This resolves an ABSENT cap. A caller who stated `legend.maxRows` never reaches here at all —
342
+ * `ChartFrame` routes around it — because `Math.min`-ing a stated number against this one made the
343
+ * documented escape inert in exactly the narrow `fill` panel it exists for
344
+ * ({@link resolveLegendMaxRows}).
275
345
  */
276
346
  export function legendEntryCap(input: {
277
347
  items: readonly LegendEntry[]
278
348
  containerW: number
279
349
  /** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
280
350
  available: number
281
- /** An explicit caller cap always wins as the upper bound. */
282
- callerMaxRows?: number
351
+ /**
352
+ * The cap that applies when nothing explicit was stated — today only the tier's own default
353
+ * ({@link chartTierMetrics}). Two defaults contending: the smaller wins.
354
+ */
355
+ defaultMaxRows?: number
283
356
  }): number | undefined {
284
- const { items, containerW, available, callerMaxRows } = input
285
- if (containerW <= 0 || items.length === 0) return callerMaxRows
357
+ const { items, containerW, available, defaultMaxRows } = input
358
+ if (containerW <= 0 || items.length === 0) return defaultMaxRows
286
359
  const rows = Math.max(
287
360
  1,
288
361
  Math.floor((available - LEGEND_PAD_Y + VX.legendGap) / (LEGEND_LINE_H + VX.legendGap)),
@@ -291,8 +364,8 @@ export function legendEntryCap(input: {
291
364
  legendBandHeight(rows) <= available &&
292
365
  entriesWithinRows(items, containerW, rows) >= items.length
293
366
  ) {
294
- return callerMaxRows
367
+ return defaultMaxRows
295
368
  }
296
369
  const fitted = Math.max(1, entriesWithinRows(items, containerW, rows))
297
- return callerMaxRows === undefined ? fitted : Math.min(fitted, callerMaxRows)
370
+ return defaultMaxRows === undefined ? fitted : Math.min(fitted, defaultMaxRows)
298
371
  }