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
@@ -17,6 +17,9 @@ function deprecatedProp(component, oldProp, newProp, removeIn) {
17
17
  `prop "${oldProp}" is deprecated \u2014 use "${newProp}" instead. "${oldProp}" is removed in ${removeIn}.`
18
18
  );
19
19
  }
20
+ function ignoredProp(component, prop, because) {
21
+ return message(component, `prop "${prop}" is ignored because ${because}`);
22
+ }
20
23
  function missingLayer(component, layer, remedy) {
21
24
  return message(component, `needs ${layer}, which is not available \u2014 ${remedy}.`);
22
25
  }
@@ -57,6 +60,7 @@ export {
57
60
  deprecatedProp,
58
61
  duplicateMount,
59
62
  errorStatus,
63
+ ignoredProp,
60
64
  missingLayer,
61
65
  oneOf,
62
66
  requiredProp,
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.30.0
1
+ # basalt-ui v1.30.2
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.30.0
22
+ Version: 1.30.2
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.30.0",
3
+ "version": "1.30.2",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -67,6 +67,7 @@ export {
67
67
  ChartFrame,
68
68
  type ChartFrameProps,
69
69
  type ChartFrameLegend,
70
+ type ResponsiveChartHeight,
70
71
  type PlotRect,
71
72
  resolveLegend,
72
73
  } from './primitives/ChartFrame'
@@ -10,6 +10,7 @@ import { useBandPlot } from '../hooks/useBandPlot'
10
10
  import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
11
11
  import { AxisBottomDate } from '../primitives/Axes'
12
12
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
13
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
13
14
  import {
14
15
  ChartTooltipFloat,
15
16
  TooltipBody,
@@ -94,9 +95,10 @@ export type BandStripProps<T> = BasaltProps & {
94
95
  series: readonly BandStripSeries<T>[]
95
96
  /** Which state each datum is in. */
96
97
  getBand: (d: T) => BandSpan
97
- /** Fixed height in px, forwarded to `ChartFrame`. The band row gets whatever the measured
98
- * margins and the measured legend band leave. Default 240. */
99
- height?: number
98
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`), forwarded to `ChartFrame`.
99
+ * The band row gets whatever the measured margins and legend band leave. Default 240.
100
+ * `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
101
+ height?: number | ResponsiveChartHeight
100
102
  /** Fill the parent flex/grid cell's measured height instead of a fixed one. */
101
103
  fill?: boolean
102
104
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
@@ -7,6 +7,7 @@ import { VX } from '../../tokens'
7
7
  import type { ChartMargin } from '../../tokens'
8
8
  import { CartesianChart } from '../primitives/CartesianChart'
9
9
  import type { AxisConfig, CartesianTooltipConfig, PlotContext } from '../primitives/CartesianChart'
10
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
10
11
  import type { ZoneSpec } from '../primitives/ZoneRects'
11
12
  import type { ChartState } from '../primitives/ChartPending'
12
13
  import { curveFor, LINE_OVERLAY_STROKE_WIDTH } from '../series'
@@ -65,8 +66,9 @@ export type BarsRefLine = {
65
66
 
66
67
  export type BarsProps<T> = BasaltProps & {
67
68
  data: T[]
68
- /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
69
- height?: number
69
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
70
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
71
+ height?: number | ResponsiveChartHeight
70
72
  chartId: string
71
73
  getX: (d: T) => string
72
74
  /** Generic value accessor — given a data point and a bar/line key, returns the value or null. */
@@ -6,6 +6,7 @@ import { assertRequiredProps } from '../../common/validate'
6
6
  import type { BasaltProps } from '../../common/props'
7
7
  import { ChartTooltipFloat, TooltipBody, TooltipRow } from '../primitives/ChartTooltip'
8
8
  import { ChartFrame } from '../primitives/ChartFrame'
9
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
9
10
  import type { ChartState } from '../primitives/ChartPending'
10
11
  import { VX } from '../../tokens'
11
12
  import type { SeriesStyle } from '../series'
@@ -20,8 +21,9 @@ export type DonutDatum<K extends string = SeriesKey> = { key: K; value: number }
20
21
 
21
22
  export type DonutProps<K extends string = SeriesKey> = BasaltProps & {
22
23
  data: DonutDatum<K>[]
23
- /** Fixed height in pixels, forwarded to the internal `ChartFrame`. Default 240. */
24
- height?: number
24
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
25
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
26
+ height?: number | ResponsiveChartHeight
25
27
  colorForKey: (key: K) => string
26
28
  formatValue: (v: number) => string
27
29
  seriesLabel?: (key: string) => string
@@ -15,6 +15,7 @@ import {
15
15
  TooltipRow,
16
16
  } from '../primitives/ChartTooltip'
17
17
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
18
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
18
19
  import { Crosshair, SeriesDot } from '../primitives/Crosshair'
19
20
  import { HoverOverlay } from '../primitives/HoverOverlay'
20
21
  import { ZoneRects } from '../primitives/ZoneRects'
@@ -31,8 +32,9 @@ import { smartTicks, xLabelPxFor } from '../utils/ticks'
31
32
 
32
33
  export type DualPanelProps<T> = BasaltProps & {
33
34
  data: T[]
34
- /** Fixed height in pixels, forwarded to the internal `ChartFrame`. 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
  getX: (d: T) => string
38
40
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM) — same default `CartesianChart` uses,
@@ -10,6 +10,7 @@ import {
10
10
  TooltipRow,
11
11
  } from '../primitives/ChartTooltip'
12
12
  import { ChartFrame } from '../primitives/ChartFrame'
13
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
13
14
  import { useChartTierMetrics } from '../primitives/chart-tier'
14
15
  import type { ChartState } from '../primitives/ChartPending'
15
16
  import { maxTextWidth } from '../utils/measure-text'
@@ -24,8 +25,10 @@ type HeatmapTip = HeatmapCell & { anchor: { x: number; y: number } }
24
25
 
25
26
  export type HeatmapProps<T> = BasaltProps & {
26
27
  data: T[]
27
- /** Fixed height in pixels. Used when neither `aspectRatio` nor `fill` is set. Default 240. */
28
- height?: number
28
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Used when neither
29
+ * `aspectRatio` nor `fill` is set. Default 240. `ChartFrameProps.height` owns the law —
30
+ * steps resolve off the MEASURED width. */
31
+ height?: number | ResponsiveChartHeight
29
32
  /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set. */
30
33
  aspectRatio?: number
31
34
  /** Fill the parent flex/grid cell's measured height instead of a fixed/derived one. */
@@ -12,6 +12,7 @@ import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
12
12
  import { probeAxisLabels } from '../layout/auto-margin'
13
13
  import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
14
14
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
15
+ import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
15
16
  import {
16
17
  ChartTooltipFloat,
17
18
  TooltipBody,
@@ -79,8 +80,9 @@ export type MirroredBarsProps<T> = BasaltProps & {
79
80
  /** Per-datum bar opacity — a real but qualified measurement (a short bucket, a partial interval)
80
81
  * drawn dimmer. Default 1. */
81
82
  getBarOpacity?: (d: T) => number
82
- /** Fixed height in px, forwarded to `ChartFrame`. Default 240. */
83
- height?: number
83
+ /** Height in px, or one per size step (`{ base, sm?, md?, lg? }`). Default 240. Forwarded
84
+ * unchanged; `ChartFrameProps.height` owns the law — steps resolve off the MEASURED width. */
85
+ height?: number | ResponsiveChartHeight
84
86
  fill?: boolean
85
87
  formatX?: (key: string) => string
86
88
  xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
@@ -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
@@ -1,15 +1,16 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
2
  import { useCallback, useState } from 'react'
3
3
  import type { BasaltProps } from '../../common/props'
4
+ import { ignoredProp } from '../../common/errors'
5
+ import { useValidateProps } from '../../common/validate'
4
6
  import { VX } from '../../tokens'
5
7
  import { useChartSize } from '../hooks/useChartSize'
6
8
  import { deriveLegend } from '../series'
7
9
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
8
10
  import {
9
- legendEntryCap,
10
11
  resolveChartTier,
11
12
  resolveFrameHeight,
12
- resolveLegendMaxRows,
13
+ resolveLegendRollup,
13
14
  resolvePlotRect,
14
15
  } from './chart-frame-layout'
15
16
  import type { ResponsiveChartHeight } from './chart-frame-layout'
@@ -20,7 +21,12 @@ import type { ChartState } from './ChartPending'
20
21
 
21
22
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
22
23
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
23
- export { legendEntryCap, resolvePlotRect, resolveChartTier } from './chart-frame-layout'
24
+ export {
25
+ legendEntryCap,
26
+ resolveLegendRollup,
27
+ resolvePlotRect,
28
+ resolveChartTier,
29
+ } from './chart-frame-layout'
24
30
  export type { ResponsiveChartHeight } from './chart-frame-layout'
25
31
 
26
32
  const DEFAULT_HEIGHT = 240
@@ -32,15 +38,22 @@ export type ChartFrameLegend = {
32
38
  /** Default 'bottom'. */
33
39
  placement?: LegendPlacement
34
40
  /**
35
- * Wrap cap → "+N more" rollup at high cardinality.
41
+ * Rollup cap at high cardinality: the legend renders this many ENTRIES and folds the remainder
42
+ * into a `+N more` disclosure chip.
36
43
  *
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}).
44
+ * **It counts entries, not rows** — the name is inherited and now load-bearing across
45
+ * `ChartTierMetrics.legendMaxRows`, so it is not renamed in a patch. `maxRows: 3` on a 7-entry
46
+ * legend renders three entries, which is ONE row at any width they fit on. `ChartLegend` slices
47
+ * (`entries.slice(0, cap)`); nothing here measures rows.
48
+ *
49
+ * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
50
+ * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
51
+ * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
52
+ * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
53
+ * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
54
+ * number a caller stated, so under `fill` the PLOT yields the height instead
55
+ * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
56
+ * gets what is left — visible, and yours.
44
57
  */
45
58
  maxRows?: number
46
59
  /** Visually separate role: series | overlay | reference. */
@@ -62,7 +75,8 @@ export type ChartFrameProps = BasaltProps & {
62
75
  series: readonly SeriesStyle[]
63
76
  /**
64
77
  * Height in pixels, or one per size step. Used when neither `aspectRatio` nor `fill` is set.
65
- * Default 240.
78
+ * Default 240. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
79
+ * this is never read, which now warns once in dev (`common/errors.ts`' `ignoredProp`).
66
80
  *
67
81
  * `{ base: 180, md: 260 }` is the declarative escape from a page of fixed-height charts: the
68
82
  * steps are compared against the frame's own MEASURED width, not the viewport, so a chart squeezed
@@ -70,7 +84,8 @@ export type ChartFrameProps = BasaltProps & {
70
84
  * supported ({@link resolveFrameHeight}).
71
85
  */
72
86
  height?: number | ResponsiveChartHeight
73
- /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set. */
87
+ /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set — which warns
88
+ * once in dev, for the same reason `height` does. */
74
89
  aspectRatio?: number
75
90
  /** Fill the parent flex/grid cell's measured height instead of a fixed/derived one. */
76
91
  fill?: boolean
@@ -215,6 +230,32 @@ export function ChartFrame({
215
230
  })
216
231
  }, [])
217
232
 
233
+ // The three sizing props are mutually exclusive and `fill` wins, silently — the type cannot say
234
+ // so without a union prop, which is a bigger change than a patch carries. Warn instead: a
235
+ // consumer passed `height` alongside `fill` for the life of a file and kept eight height
236
+ // constants (and a whole `compact` branch) that never moved a pixel, with nothing from tsc,
237
+ // oxlint or check-theme saying a word.
238
+ useValidateProps(
239
+ 'ChartFrame',
240
+ () => [
241
+ fill && height !== undefined
242
+ ? ignoredProp(
243
+ 'ChartFrame',
244
+ 'height',
245
+ "`fill` is set — a fill frame takes its parent cell's measured height. Pass one or the other.",
246
+ )
247
+ : null,
248
+ fill && aspectRatio !== undefined
249
+ ? ignoredProp(
250
+ 'ChartFrame',
251
+ 'aspectRatio',
252
+ "`fill` is set — a fill frame takes its parent cell's measured height. Pass one or the other.",
253
+ )
254
+ : null,
255
+ ],
256
+ [fill, height, aspectRatio],
257
+ )
258
+
218
259
  const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
219
260
  const vertical = placement === 'left' || placement === 'right'
220
261
  // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
@@ -232,38 +273,32 @@ export function ChartFrame({
232
273
  const sideLegendWidth = legendVisible && vertical ? legendW : 0
233
274
  const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
234
275
 
276
+ const legendItems = legend === false ? [] : deriveLegend(series)
277
+ const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
278
+
279
+ // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
280
+ // cases and why the composition, not either half, is what shipped broken). A fixed-height frame
281
+ // grows around its legend; a `fill` one cannot, so its legend rolls up to the measured fit
282
+ // instead of eating the plot — unless the caller stated a number, which nothing trims and the
283
+ // plot pays for. Top/bottom only: a side legend costs width, not height.
284
+ const { maxRows, legendWins } = resolveLegendRollup({
285
+ ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
286
+ tier,
287
+ fillBand: legendVisible && fill && !vertical,
288
+ items: legendItems,
289
+ containerW,
290
+ available: resolvedHeight - VX.minPlotHeight,
291
+ })
292
+
235
293
  const plot = resolvePlotRect({
236
294
  containerW,
237
295
  resolvedHeight,
238
296
  minWidth,
239
297
  sideLegendWidth,
240
298
  topBottomLegendHeight,
299
+ ...(legendWins && { legendWins }),
241
300
  })
242
301
 
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
302
  const legendNode =
268
303
  legend === false || resolvedState !== null ? null : (
269
304
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
@@ -255,9 +255,11 @@ function LegendChild({ item }: { item: LegendEntry }) {
255
255
  * Shared legend for all non-sparkline charts. `highlighted`/`onHighlight` are optional — omit for
256
256
  * static legends without hover interactivity. `chartId` namespaces `split` clipPath ids so two
257
257
  * legends on the same page never collide. `groups` renders series → hairline divider → overlay →
258
- * reference bands instead of one flat smear. `maxRows` caps the number of rendered entries and
259
- * rolls the remainder into a `+N more` chip (row-accurate wrap measurement is `ChartFrame`'s job —
260
- * this is an entry-count cap, a deliberate simplification until that primitive lands).
258
+ * reference bands instead of one flat smear. `maxRows` caps the number of rendered ENTRIES and
259
+ * rolls the remainder into a `+N more` chip — it is an entry count, not a row count, in spite of
260
+ * the name (`entries.slice(0, cap)` below is the whole of it, and `ChartFrame` measures rows for a
261
+ * different purpose in `legendEntryCap`). The name is inherited and reaches the public
262
+ * `ChartTierMetrics.legendMaxRows`, so it stays until a minor can rename both.
261
263
  *
262
264
  * `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
263
265
  * and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`