diff --git a/apps/web/src/components/widget-lab/scenarios.ts b/apps/web/src/components/widget-lab/scenarios.ts index 1a176682..c1839db1 100644 --- a/apps/web/src/components/widget-lab/scenarios.ts +++ b/apps/web/src/components/widget-lab/scenarios.ts @@ -545,6 +545,21 @@ export const stressScenarios: ChartScenario[] = [ chartPresentation: { legend: "right", seriesStats: false }, }, }, + { + // Line keeps every series (no "Other" bucketing), so the right legend is + // genuinely tall — confirms it scrolls within the card instead of overflowing. + label: "Line — 50 series (right legend, scrolls)", + chartId: "query-builder-line", + chartName: "Query Builder Line", + category: "line", + dataState: ready(makeManySeries(50)), + display: { + title: "Latency by service (50, right legend)", + chartId: "query-builder-line", + unit: "duration_ms", + chartPresentation: { legend: "right", seriesStats: true }, + }, + }, { label: "Pie — 20 slices → Other", chartId: "query-builder-pie", diff --git a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx index 3d5faa61..679a5bb1 100644 --- a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx +++ b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx @@ -60,6 +60,12 @@ interface QueryBuilderLegendProps { * per-series Min/Max/Mean/Last columns. */ variant?: "compact" | "stats" + /** + * Upper bound (px) on the legend's own height. A right-aligned legend isn't + * height-constrained by Recharts, so a long series list would overflow the + * card; capping it here lets the `overflow-auto` body scroll instead. + */ + maxHeight?: number } /** Vertical space (px) a bottom-aligned legend block needs. */ @@ -120,12 +126,16 @@ export function QueryBuilderLegend({ unit, layout = "bottom", variant = "stats", + maxHeight, }: QueryBuilderLegendProps) { if (series.length === 0) return null + const maxHeightStyle = maxHeight != null ? { maxHeight } : undefined + if (variant === "compact") { return (
+
diff --git a/packages/ui/src/components/charts/area/query-builder-area-chart.tsx b/packages/ui/src/components/charts/area/query-builder-area-chart.tsx index e4023956..04cc0898 100644 --- a/packages/ui/src/components/charts/area/query-builder-area-chart.tsx +++ b/packages/ui/src/components/charts/area/query-builder-area-chart.tsx @@ -220,7 +220,11 @@ export function QueryBuilderAreaChart({ return (
- + {seriesDefinitions.map((definition) => ( @@ -360,6 +364,7 @@ export function QueryBuilderAreaChart({ unit={unit} layout="right" variant={variant} + maxHeight={containerHeight} /> } /> diff --git a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx index 67343ffc..8da0b802 100644 --- a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx +++ b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx @@ -186,7 +186,11 @@ export function QueryBuilderBarChart({ return (
- + } /> diff --git a/packages/ui/src/components/charts/line/query-builder-line-chart.tsx b/packages/ui/src/components/charts/line/query-builder-line-chart.tsx index 99483e65..97be15d6 100644 --- a/packages/ui/src/components/charts/line/query-builder-line-chart.tsx +++ b/packages/ui/src/components/charts/line/query-builder-line-chart.tsx @@ -229,7 +229,11 @@ export function QueryBuilderLineChart({ return (
- + } /> diff --git a/packages/ui/src/components/ui/chart.tsx b/packages/ui/src/components/ui/chart.tsx index f9eaece4..92fc53bd 100644 --- a/packages/ui/src/components/ui/chart.tsx +++ b/packages/ui/src/components/ui/chart.tsx @@ -37,6 +37,9 @@ function useChart() { export type ChartLegendItem = { key: string; label: React.ReactNode; color?: string } +/** Stable empty reference so the legend-slot publish effect doesn't churn. */ +const EMPTY_LEGEND_ITEMS: ChartLegendItem[] = [] + /** * Optional slot for hoisting a chart's legend out of the plot area and into an * ancestor (e.g. a card header). When a provider is present, `ChartContainer` @@ -52,10 +55,18 @@ function ChartContainer({ className, children, config, + hoistLegend = true, ...props }: React.ComponentProps<"div"> & { config: ChartConfig children: React.ComponentProps["children"] + /** + * When `true` (default) and a {@link ChartLegendSlotContext} ancestor is + * present, the chart's series are published into that slot (e.g. a widget + * header strip). Charts that render their own in-plot legend should pass + * `false` so the header doesn't duplicate it. + */ + hoistLegend?: boolean }) { const uniqueId = React.useId() const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` @@ -73,11 +84,12 @@ function ChartContainer({ })), [config], ) + const publishedItems = hoistLegend ? legendItems : EMPTY_LEGEND_ITEMS React.useEffect(() => { if (!legendSlot) return - legendSlot.setItems(legendItems) + legendSlot.setItems(publishedItems) return () => legendSlot.setItems([]) - }, [legendSlot, legendItems]) + }, [legendSlot, publishedItems]) return (