basalt-ui 1.30.0 → 1.30.2

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 (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/MIGRATING.md +284 -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 +20 -11
  26. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  27. package/dist/charts/primitives/ChartFrame.js +39 -14
  28. package/dist/charts/primitives/ChartLegend.d.ts +5 -3
  29. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  30. package/dist/charts/primitives/chart-frame-layout.d.ts +64 -9
  31. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  32. package/dist/charts/primitives/chart-frame-layout.js +35 -6
  33. package/dist/common/errors.d.ts +11 -0
  34. package/dist/common/errors.d.ts.map +1 -1
  35. package/dist/common/errors.js +4 -0
  36. package/llms.txt +2 -2
  37. package/package.json +1 -1
  38. package/src/charts/index.ts +1 -0
  39. package/src/charts/kinds/BandStrip.tsx +5 -3
  40. package/src/charts/kinds/Bars.tsx +4 -2
  41. package/src/charts/kinds/Donut.tsx +4 -2
  42. package/src/charts/kinds/DualPanel.tsx +4 -2
  43. package/src/charts/kinds/Heatmap.tsx +5 -2
  44. package/src/charts/kinds/MirroredBars.tsx +4 -2
  45. package/src/charts/kinds/MultiLine.tsx +4 -2
  46. package/src/charts/kinds/StackedArea.tsx +4 -2
  47. package/src/charts/kinds/ZonedLine.tsx +4 -2
  48. package/src/charts/primitives/CartesianChart.tsx +4 -1
  49. package/src/charts/primitives/ChartFrame.tsx +72 -37
  50. package/src/charts/primitives/ChartLegend.tsx +5 -3
  51. package/src/charts/primitives/chart-frame-layout.ts +123 -16
  52. package/src/common/errors.ts +14 -0
@@ -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,12 @@ 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, but only as far as the box HAS room
264
+ * ({@link SELF_MEASURED_SLACK}). An unmeasured box (`resolvedHeight <= 0`, i.e. a `fill` frame
265
+ * before its first measurement) stays at 0 and renders nothing, exactly as before — the floor
266
+ * must never invent a height for a box nobody has measured.
217
267
  */
218
268
  export function resolvePlotRect(input: {
219
269
  containerW: number
@@ -221,15 +271,64 @@ export function resolvePlotRect(input: {
221
271
  minWidth: number
222
272
  sideLegendWidth: number
223
273
  topBottomLegendHeight: number
274
+ /**
275
+ * The caller stated `legend.maxRows` on a `fill` frame, so the legend takes the rows it asked
276
+ * for and the plot takes what is left — `VX.minPlotHeight` stops being a floor. Without this the
277
+ * honoured cap would push the frame's content PAST its own cell (the plot holds 120px, the legend
278
+ * band adds its own) and paint over whatever sits below it; with it, the cost of the caller's
279
+ * number lands where they can see it. Spend the whole box on legend rows and there is no plot
280
+ * left to draw — that is their arithmetic, made visible rather than silently rolled up.
281
+ */
282
+ legendWins?: boolean
224
283
  }): { width: number; height: number } {
225
- const { containerW, resolvedHeight, minWidth, sideLegendWidth, topBottomLegendHeight } = input
284
+ const {
285
+ containerW,
286
+ resolvedHeight,
287
+ minWidth,
288
+ sideLegendWidth,
289
+ topBottomLegendHeight,
290
+ legendWins = false,
291
+ } = input
292
+ const room = resolvedHeight - topBottomLegendHeight
293
+ const plotFloor = legendWins && !isSelfMeasured(room) ? 0 : VX.minPlotHeight
226
294
  return {
227
295
  width: containerW === 0 ? minWidth : Math.max(containerW - sideLegendWidth, 1),
228
- height:
229
- resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, VX.minPlotHeight),
296
+ height: resolvedHeight <= 0 ? 0 : Math.max(room, plotFloor),
230
297
  }
231
298
  }
232
299
 
300
+ /**
301
+ * Sub-pixel slack on "the frame measures its own legend band and nothing else".
302
+ *
303
+ * MEASURED exact in the collapse this guards (both `ResizeObserver` contentRects read 29.375 in
304
+ * headless Chrome, `tests/layout/charts.layout.test.ts` INVARIANT 7) — the slack exists only so a
305
+ * future rounding or border difference between the two observers cannot re-open a fixpoint that
306
+ * has no other way out. A plot this thin has nothing to draw either way.
307
+ */
308
+ const SELF_MEASURED_SLACK = 0.5
309
+
310
+ /**
311
+ * Is the frame's height its OWN content rather than a box something else stated?
312
+ *
313
+ * `ChartFrame` measures its own node (`useChartSize` → visx `useParentSize` observes the element
314
+ * the ref is attached to, its name notwithstanding), and under `fill` its `height: 100%` resolves
315
+ * to `auto` in any parent that states no height. Its only children are the plot and the legend, so
316
+ * `room === 0` means the box IS the legend band — which happens exactly when no plot was drawn.
317
+ *
318
+ * That is why `legendWins` cannot yield the floor there: with the floor gone the sequence has a
319
+ * FIXPOINT AT ZERO — plot 0 → content is the legend → box = band → room 0 → plot 0, forever, and
320
+ * the chart never appears. Keeping the floor while `room` is 0 converges on the same box a `fill`
321
+ * frame in an unsized parent has always had (plot `VX.minPlotHeight`, box = plot + band), from
322
+ * which `room` is positive and `legendWins` applies normally.
323
+ *
324
+ * A NEGATIVE `room` is not this case and must keep yielding: a self-measured box can never be
325
+ * shorter than its own content, so a band taller than the box means the box was stated by
326
+ * something else, and spending all of it on legend rows is the caller's own arithmetic.
327
+ */
328
+ function isSelfMeasured(room: number): boolean {
329
+ return room >= 0 && room < SELF_MEASURED_SLACK
330
+ }
331
+
233
332
  /** Height of a legend band `rows` rows tall, wrapper padding included. */
234
333
  const legendBandHeight = (rows: number): number =>
235
334
  LEGEND_PAD_Y + rows * LEGEND_LINE_H + Math.max(rows - 1, 0) * VX.legendGap
@@ -272,17 +371,25 @@ function entriesWithinRows(items: readonly LegendEntry[], width: number, rows: n
272
371
  * MEASURING the labels, not from assuming one entry per row — otherwise a five-entry legend that
273
372
  * fits on one line would roll up to `+2 more` in every 240px cell, moving rendering for charts
274
373
  * that never had the bug. Returns `undefined` when the whole legend already fits.
374
+ *
375
+ * This resolves an ABSENT cap. A caller who stated `legend.maxRows` never reaches here at all —
376
+ * `ChartFrame` routes around it — because `Math.min`-ing a stated number against this one made the
377
+ * documented escape inert in exactly the narrow `fill` panel it exists for
378
+ * ({@link resolveLegendMaxRows}).
275
379
  */
276
380
  export function legendEntryCap(input: {
277
381
  items: readonly LegendEntry[]
278
382
  containerW: number
279
383
  /** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
280
384
  available: number
281
- /** An explicit caller cap always wins as the upper bound. */
282
- callerMaxRows?: number
385
+ /**
386
+ * The cap that applies when nothing explicit was stated — today only the tier's own default
387
+ * ({@link chartTierMetrics}). Two defaults contending: the smaller wins.
388
+ */
389
+ defaultMaxRows?: number
283
390
  }): number | undefined {
284
- const { items, containerW, available, callerMaxRows } = input
285
- if (containerW <= 0 || items.length === 0) return callerMaxRows
391
+ const { items, containerW, available, defaultMaxRows } = input
392
+ if (containerW <= 0 || items.length === 0) return defaultMaxRows
286
393
  const rows = Math.max(
287
394
  1,
288
395
  Math.floor((available - LEGEND_PAD_Y + VX.legendGap) / (LEGEND_LINE_H + VX.legendGap)),
@@ -291,8 +398,8 @@ export function legendEntryCap(input: {
291
398
  legendBandHeight(rows) <= available &&
292
399
  entriesWithinRows(items, containerW, rows) >= items.length
293
400
  ) {
294
- return callerMaxRows
401
+ return defaultMaxRows
295
402
  }
296
403
  const fitted = Math.max(1, entriesWithinRows(items, containerW, rows))
297
- return callerMaxRows === undefined ? fitted : Math.min(fitted, callerMaxRows)
404
+ return defaultMaxRows === undefined ? fitted : Math.min(fitted, defaultMaxRows)
298
405
  }
@@ -55,6 +55,20 @@ export function deprecatedProp(
55
55
  )
56
56
  }
57
57
 
58
+ /**
59
+ * A prop the component ACCEPTS, reads nothing from, and cannot report as wrong — because a SECOND
60
+ * prop it was passed alongside decides the same thing and won.
61
+ *
62
+ * Distinct from {@link deprecatedProp} (still read, going away) and {@link oneOf} (read, and the
63
+ * value is out of range): this one renders something the caller did not ask for and nothing in the
64
+ * type system, the linter or the console says so. `ChartFrame`'s `height` under `fill` is the
65
+ * reference case — meteo passed both for the life of the file and never learnt that eight height
66
+ * constants and a whole `compact` branch changed no pixel.
67
+ */
68
+ export function ignoredProp(component: string, prop: string, because: string): string {
69
+ return message(component, `prop "${prop}" is ignored because ${because}`)
70
+ }
71
+
58
72
  /**
59
73
  * An imperative call needs a layer that is not there — the optional peer is not installed, or the
60
74
  * mount that subscribes to its event bus is disabled. Both halves are named because the remedy