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 (