diff --git a/.changeset/wet-dancers-type.md b/.changeset/wet-dancers-type.md new file mode 100644 index 0000000000..65c0e7b4bc --- /dev/null +++ b/.changeset/wet-dancers-type.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Extend LegendItems to take pointer events & timeseries merges forwardref to support those events diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx index 02a79827f7..91a902dd27 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx @@ -8,7 +8,7 @@ import { import * as echarts from "echarts/core"; import type { EChartsOption } from "echarts"; import { BarChart, LineChart, PieChart } from "echarts/charts"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AriaComponent, AxisPointerComponent, @@ -540,6 +540,98 @@ export function ChartExampleDemo() { ); } +/** + * Timeseries chart with legend items that highlight the corresponding series on hover. + * Hovering a legend item dispatches a highlight action to the chart and fades the other legend items. + */ +export function LegendHighlightDemo() { + const isDarkMode = useIsDarkMode(); + const chartRef = useRef(null); + const [hoveredSeries, setHoveredSeries] = useState(null); + + const series = useMemo( + () => [ + { + name: "P99", + color: ChartPalette.semantic("Attention", isDarkMode), + value: "124", + unit: "ms", + }, + { + name: "P95", + color: ChartPalette.semantic("Warning", isDarkMode), + value: "76", + unit: "ms", + }, + { + name: "P75", + color: ChartPalette.semantic("Neutral", isDarkMode), + value: "32", + unit: "ms", + }, + { + name: "P50", + color: ChartPalette.semantic("NeutralLight", isDarkMode), + value: "10", + unit: "ms", + }, + ], + [isDarkMode], + ); + + const data = useMemo( + () => + series.map((s, i) => ({ + name: s.name, + data: buildSeriesData(3 - i, 30, 60_000, 1 - i * 0.2), + color: s.color, + })), + [series], + ); + + return ( + + Read latency + +
+ {series.map((s) => ( + { + setHoveredSeries(s.name); + chartRef.current?.dispatchAction({ + type: "highlight", + seriesName: s.name, + }); + }} + onPointerLeave={() => { + setHoveredSeries(null); + chartRef.current?.dispatchAction({ + type: "downplay", + seriesName: s.name, + }); + }} + /> + ))} +
+ +
+
+ ); +} + function buildSeriesData( seed = 0, points = 50, diff --git a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx index a80636f718..746b52fccf 100644 --- a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx +++ b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx @@ -13,6 +13,7 @@ import { CustomAxisLabelFormatDemo, GradientLineChartDemo, IncompleteDataChartDemo, + LegendHighlightDemo, LoadingChartDemo, TimeRangeSelectionChartDemo, } from "~/components/demos/Chart/ChartDemo"; @@ -101,6 +102,21 @@ import Heading from "~/components/docs/Heading.astro"; + + Legend Highlight + +

+ Hovering a legend item highlights the corresponding series on the chart and + fades the others. Use onPointerEnter and{" "} + onPointerLeave on ChartLegend items together with{" "} + dispatchAction on the chart ref. +

+ + + + +
+ Loading State diff --git a/packages/kumo/src/components/chart/Legend.tsx b/packages/kumo/src/components/chart/Legend.tsx index 31e4c6dced..bd9717f6b2 100644 --- a/packages/kumo/src/components/chart/Legend.tsx +++ b/packages/kumo/src/components/chart/Legend.tsx @@ -1,5 +1,16 @@ +import type { + KeyboardEventHandler, + MouseEventHandler, + PointerEventHandler, +} from "react"; import { cn } from "../../utils"; +const onInteractiveKeyDown: KeyboardEventHandler = (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.currentTarget.click(); +}; + /** Shared props for both legend item variants */ interface LegendItemProps { /** Series name shown as a label */ @@ -12,6 +23,14 @@ interface LegendItemProps { unit?: string; /** When `true`, renders the item at 50% opacity to indicate a deselected state */ inactive?: boolean; + /** Fired when a pointer enters the legend item — useful for highlighting the corresponding chart series */ + onPointerEnter?: PointerEventHandler; + /** Fired when a pointer leaves the legend item — useful for resetting chart series emphasis */ + onPointerLeave?: PointerEventHandler; + /** Fired when the legend item is clicked — useful for toggling series visibility */ + onClick?: MouseEventHandler; + /** Optional className to customize legend item presentation */ + className?: string; } /** @@ -19,9 +38,32 @@ interface LegendItemProps { * and a large value with an optional small unit below. Use for prominent * single-metric displays such as dashboard cards. */ -function LargeItem({ color, value, name, unit, inactive }: LegendItemProps) { +function LargeItem({ + color, + value, + name, + unit, + inactive, + onPointerEnter, + onPointerLeave, + onClick, + className, +}: LegendItemProps) { return ( -
+
+
* ``` */ -export function TimeseriesChart({ - echarts, - type = "line", - data, - xAxisName, - xAxisTickCount, - xAxisTickFormat, - yAxisTickFormat, - yAxisTickLabelFormat, - yAxisName, - yAxisTickCount, - tooltipValueFormat, - onTimeRangeChange, - height = 350, - incomplete, - isDarkMode, - gradient, - loading, - ariaDescription, -}: TimeseriesChartProps) { +export const TimeseriesChart = forwardRef< + echarts.ECharts | null, + TimeseriesChartProps +>(function TimeseriesChart( + { + echarts, + type = "line", + data, + xAxisName, + xAxisTickCount, + xAxisTickFormat, + yAxisTickFormat, + yAxisTickLabelFormat, + yAxisName, + yAxisTickCount, + tooltipValueFormat, + onTimeRangeChange, + height = 350, + incomplete, + isDarkMode, + gradient, + loading, + ariaDescription, + }, + ref, +) { const chartRef = useRef(null); + + const mergedRef = useCallback( + (instance: echarts.ECharts | null) => { + chartRef.current = instance; + if (typeof ref === "function") { + ref(instance); + } else if (ref) { + ref.current = instance; + } + }, + [ref], + ); + const incompleteBefore = incomplete?.before; const incompleteAfter = incomplete?.after; @@ -378,7 +397,7 @@ export function TimeseriesChart({ {!loading && ( ); -} +}); + +TimeseriesChart.displayName = "TimeseriesChart"; /** * Animated sine-wave skeleton shown while `TimeseriesChart` is in `loading` state.