From 5d96606bd7cdf3a9090a137704249d584d9c2aa6 Mon Sep 17 00:00:00 2001 From: Anthony Benites Date: Mon, 9 Mar 2026 07:09:41 +0000 Subject: [PATCH] feat: add dual y-axis support to CartesianChart Allow CartesianChart to render two Y axes by passing a tuple to the yAxis prop. The second axis is automatically set to opposite (right side). Series can reference the secondary axis with yAxis: 1. Changes: - Add yAxis?: number to BaseCartesianSeriesOptions - Accept yAxis as single or [primary, secondary] tuple in CartesianChartProps - Pass yAxis through all series transform functions - Auto-set opposite: true on the secondary axis - Add unit tests and demo page --- .../dual-axis-chart.page.tsx | 82 ++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 79 +++------------- .../__tests__/cartesian-chart-series.test.tsx | 94 +++++++++++++++++++ .../chart-cartesian-internal.tsx | 3 +- .../chart-series-cartesian.tsx | 13 ++- src/cartesian-chart/index.tsx | 41 ++++++-- src/cartesian-chart/interfaces.ts | 13 ++- src/core/__tests__/chart-core-legend.test.tsx | 16 ++-- src/core/__tests__/common.tsx | 6 +- src/core/chart-core.tsx | 5 +- src/core/interfaces.ts | 8 +- src/core/utils.ts | 9 +- .../components/chart-legend/index.tsx | 3 + src/test-utils/dom/internal/base.ts | 14 ++- src/test-utils/dom/internal/core.ts | 17 +--- 15 files changed, 294 insertions(+), 109 deletions(-) create mode 100644 pages/01-cartesian-chart/dual-axis-chart.page.tsx diff --git a/pages/01-cartesian-chart/dual-axis-chart.page.tsx b/pages/01-cartesian-chart/dual-axis-chart.page.tsx new file mode 100644 index 00000000..f5d22b93 --- /dev/null +++ b/pages/01-cartesian-chart/dual-axis-chart.page.tsx @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CartesianChart, CartesianChartProps } from "../../lib/components"; +import { dateFormatter } from "../common/formatters"; +import { useChartSettings } from "../common/page-settings"; +import { Page } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + +function randomInt(min: number, max: number) { + return min + Math.floor(pseudoRandom() * (max - min)); +} + +const baseline = [ + { x: 1600984800000, y: 58020 }, + { x: 1600985700000, y: 102402 }, + { x: 1600986600000, y: 104920 }, + { x: 1600987500000, y: 94031 }, + { x: 1600988400000, y: 125021 }, + { x: 1600989300000, y: 159219 }, + { x: 1600990200000, y: 193082 }, + { x: 1600991100000, y: 162592 }, + { x: 1600992000000, y: 274021 }, + { x: 1600992900000, y: 264286 }, + { x: 1600993800000, y: 289210 }, + { x: 1600994700000, y: 256362 }, + { x: 1600995600000, y: 257306 }, + { x: 1600996500000, y: 186776 }, + { x: 1600997400000, y: 294020 }, + { x: 1600998300000, y: 385975 }, + { x: 1600999200000, y: 486039 }, + { x: 1601000100000, y: 490447 }, + { x: 1601001000000, y: 361845 }, + { x: 1601001900000, y: 339058 }, + { x: 1601002800000, y: 298028 }, + { x: 1601003400000, y: 255555 }, + { x: 1601003700000, y: 231902 }, + { x: 1601004600000, y: 224558 }, + { x: 1601005500000, y: 253901 }, + { x: 1601006400000, y: 102839 }, + { x: 1601007300000, y: 234943 }, + { x: 1601008200000, y: 204405 }, + { x: 1601009100000, y: 190391 }, + { x: 1601010000000, y: 183570 }, + { x: 1601010900000, y: 162592 }, + { x: 1601011800000, y: 148910 }, +]; + +const eventsData = baseline.map(({ x, y }) => ({ x, y: y + randomInt(-50000, 50000) })); +const percentageData = baseline.map(({ x, y }) => ({ x, y: (y / 10000) * randomInt(3, 10) })); + +const dualAxisProps = { + chartHeight: 400, + xAxis: { + title: "Time (UTC)", + type: "datetime" as const, + valueFormatter: dateFormatter, + }, + yAxis: [ + { id: "events", title: "Events" }, + { id: "percentage", title: "Percentage (%)" }, + ] as [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId], +}; + +export default function () { + const { chartProps } = useChartSettings(); + return ( + + + + ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 6d29eee6..0f0f5c6f 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -562,9 +562,13 @@ applies to the tooltip header.", }, { "description": "Defines options of the chart's y axis. -This property corresponds to [xAxis](https://api.highcharts.com/highcharts/yAxis), and extends it +This property corresponds to [yAxis](https://api.highcharts.com/highcharts/yAxis), and extends it with a custom value formatter. +Use a single object for a single y axis, or a tuple of two objects for a dual-axis chart. +When using a tuple, both axes must have an \`id\`. The second axis is automatically rendered on the opposite (right) side. +Series reference their axis by setting \`yAxis\` to the axis \`id\`. + Supported options: * \`title\` (optional, string) - Axis title. * \`type\` (optional, "linear" | "datetime" | "category" | "logarithmic") - Axis type. @@ -580,75 +584,16 @@ Supported options: * \`valueFormatter\` (optional, function) - Takes axis tick as input and returns a formatted string. This formatter also applies to the tooltip points values.", "inlineType": { - "name": "CartesianChartProps.YAxisOptions", - "properties": [ - { - "name": "categories", - "optional": true, - "type": "Array", - }, - { - "name": "max", - "optional": true, - "type": "number", - }, - { - "name": "min", - "optional": true, - "type": "number", - }, - { - "name": "reversedStacks", - "optional": true, - "type": "boolean", - }, - { - "name": "tickInterval", - "optional": true, - "type": "number", - }, - { - "name": "title", - "optional": true, - "type": "string", - }, - { - "inlineType": { - "name": ""category" | "datetime" | "linear" | "logarithmic"", - "type": "union", - "values": [ - "category", - "datetime", - "linear", - "logarithmic", - ], - }, - "name": "type", - "optional": true, - "type": "string", - }, - { - "inlineType": { - "name": "(value: number | null) => string", - "parameters": [ - { - "name": "value", - "type": "number | null", - }, - ], - "returnType": "string", - "type": "function", - }, - "name": "valueFormatter", - "optional": true, - "type": "((value: number | null) => string)", - }, + "name": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions]", + "type": "union", + "values": [ + "CartesianChartProps.YAxisOptions", + "[CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions]", ], - "type": "object", }, "name": "yAxis", "optional": true, - "type": "CartesianChartProps.YAxisOptions", + "type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions]", }, ], "regions": [ @@ -1764,7 +1709,7 @@ not overridden, but merged with Cloudscape event handlers so that both are getti "type": "union", "valueDescriptions": undefined, "values": [ - "Omit", + "Omit", "{ xAxis?: CoreChartProps.XAxisOptions | Array | undefined; yAxis?: CoreChartProps.YAxisOptions | Array | undefined; }", ], }, diff --git a/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx index 39499771..03e6ae9b 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx @@ -157,4 +157,98 @@ describe("CartesianChart: series", () => { }); expect((hc.getChartSeries(0).options as SeriesLineOptions).dashStyle).toBe("Solid"); }); + + test("series yAxis is passed through to Highcharts", () => { + renderCartesianChart({ + highcharts, + series: [ + { type: "line", name: "Primary", data: [{ x: 1, y: 1 }], yAxis: "primary" }, + { type: "line", name: "Secondary", data: [{ x: 1, y: 2 }], yAxis: "secondary" }, + ], + yAxis: [ + { id: "primary", title: "Primary" }, + { id: "secondary", title: "Secondary" }, + ], + }); + expect(hc.getChartSeries(0).options.yAxis).toBe("primary"); + expect(hc.getChartSeries(1).options.yAxis).toBe("secondary"); + }); + + test("dual yAxis renders two axes with opposite on the secondary", () => { + renderCartesianChart({ + highcharts, + series: [ + { type: "line", name: "Primary", data: [{ x: 1, y: 1 }], yAxis: "primary" }, + { type: "line", name: "Secondary", data: [{ x: 1, y: 2 }], yAxis: "secondary" }, + ], + yAxis: [ + { id: "primary", title: "Primary" }, + { id: "secondary", title: "Secondary" }, + ], + }); + const chart = hc.getChart(); + expect(chart.yAxis).toHaveLength(2); + expect(chart.yAxis[0].options.opposite).toBeFalsy(); + expect(chart.yAxis[1].options.opposite).toBe(true); + }); + + test("series yAxis is not set when not provided", () => { + renderCartesianChart({ + highcharts, + series: [{ type: "line", name: "Line", data: [{ x: 1, y: 1 }] }], + }); + expect(hc.getChartSeries(0).options.yAxis).toBeUndefined(); + }); + + test("findLegend with axisId returns null for single-axis chart", () => { + renderCartesianChart({ + highcharts, + series: [{ type: "line", name: "Line", data: [{ x: 1, y: 1 }] }], + }); + expect(getChart().findLegend({ axisId: "secondary" })).toBeNull(); + }); + + test("findLegend with axisId returns secondary legend for dual-axis chart", () => { + renderCartesianChart({ + highcharts, + series: [ + { type: "line", name: "Primary", data: [{ x: 1, y: 1 }], yAxis: "primary" }, + { type: "line", name: "Secondary", data: [{ x: 1, y: 2 }], yAxis: "secondary" }, + ], + yAxis: [ + { id: "primary", title: "Primary" }, + { id: "secondary", title: "Secondary" }, + ], + }); + const secondaryLegend = getChart().findLegend({ axisId: "secondary" }); + expect(secondaryLegend).not.toBeNull(); + expect(secondaryLegend!.findItems()).toHaveLength(1); + expect(secondaryLegend!.findItems()[0].getElement().textContent).toBe("Secondary"); + }); + + test("findYAxisTitle with axisId returns null for single-axis chart", () => { + renderCartesianChart({ + highcharts, + series: [{ type: "line", name: "Line", data: [{ x: 1, y: 1 }] }], + yAxis: { title: "Only axis" }, + }); + expect(getChart().findYAxisTitle({ axisId: "secondary" })).toBeNull(); + }); + + test("findYAxisTitle with axisId returns secondary axis title for dual-axis chart", () => { + renderCartesianChart({ + highcharts, + series: [ + { type: "line", name: "Primary", data: [{ x: 1, y: 1 }], yAxis: "primary" }, + { type: "line", name: "Secondary", data: [{ x: 1, y: 2 }], yAxis: "secondary" }, + ], + yAxis: [ + { id: "primary", title: "Primary axis" }, + { id: "secondary", title: "Secondary axis" }, + ], + }); + const secondaryTitle = getChart().findYAxisTitle({ axisId: "secondary" }); + expect(secondaryTitle).not.toBeNull(); + expect(secondaryTitle!.getElement().textContent).toBe("Secondary axis"); + }); }); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index be4ff121..6c0d0f7a 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -116,10 +116,11 @@ export const InternalCartesianChart = forwardRef( title: { text: xAxisProps.title }, plotLines: xPlotLines, })), - yAxis: castArray(props.yAxis)?.map((yAxisProps) => ({ + yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({ ...yAxisProps, title: { text: yAxisProps.title }, plotLines: yPlotLines, + ...(index === 1 ? { opposite: true } : {}), })), }} tooltip={tooltip} diff --git a/src/cartesian-chart/chart-series-cartesian.tsx b/src/cartesian-chart/chart-series-cartesian.tsx index 59bb3752..030227dc 100644 --- a/src/cartesian-chart/chart-series-cartesian.tsx +++ b/src/cartesian-chart/chart-series-cartesian.tsx @@ -53,7 +53,18 @@ export const transformCartesianSeries = ( color: s.color ?? Styles.thresholdSeries.color, dashStyle: s.dashStyle ?? Styles.thresholdSeries.dashStyle, }; - return { type: "line", id: s.id, name: s.name, data, custom, enableMouseTracking, ...style, ...shared }; + const yAxis = s.type === "y-threshold" ? s.yAxis : undefined; + return { + type: "line", + id: s.id, + name: s.name, + yAxis, + data, + custom, + enableMouseTracking, + ...style, + ...shared, + }; } if (s.type === "errorbar") { const color = s.color ?? colorChartsErrorBarMarker; diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx index e7188817..fa1994ac 100644 --- a/src/cartesian-chart/index.tsx +++ b/src/cartesian-chart/index.tsx @@ -108,24 +108,33 @@ function transformLineLikeSeries< | CartesianChartProps.SplineSeriesOptions, >(s: S): null | S { const data = transformPointData(s.data); - return { type: s.type, id: s.id, name: s.name, color: s.color, dashStyle: s.dashStyle, data } as S; + return { type: s.type, id: s.id, name: s.name, color: s.color, dashStyle: s.dashStyle, yAxis: s.yAxis, data } as S; } function transformColumnSeries(s: S): null | S { const data = transformPointData(s.data); - return { type: s.type, id: s.id, name: s.name, color: s.color, data } as S; + return { type: s.type, id: s.id, name: s.name, color: s.color, yAxis: s.yAxis, data } as S; } function transformScatterSeries(s: S): null | S { const data = transformPointData(s.data); const marker = s.marker ?? {}; - return { type: s.type, id: s.id, name: s.name, color: s.color, data, marker } as S; + return { type: s.type, id: s.id, name: s.name, color: s.color, yAxis: s.yAxis, data, marker } as S; } function transformThresholdSeries< S extends CartesianChartProps.XThresholdSeriesOptions | CartesianChartProps.YThresholdSeriesOptions, >(s: S): null | S { - return { type: s.type, id: s.id, name: s.name, color: s.color, value: s.value, dashStyle: s.dashStyle } as S; + const yAxis = s.type === "y-threshold" ? s.yAxis : undefined; + return { + type: s.type, + id: s.id, + name: s.name, + color: s.color, + yAxis, + value: s.value, + dashStyle: s.dashStyle, + } as S; } function transformErrorBarSeries( @@ -157,7 +166,15 @@ function transformErrorBarSeries( return null; } const data = transformRangeData(series.data); - return { type: series.type, id: series.id, name: series.name, color: series.color, linkedTo: series.linkedTo, data }; + return { + type: series.type, + id: series.id, + name: series.name, + color: series.color, + yAxis: series.yAxis, + linkedTo: series.linkedTo, + data, + }; } function transformPointData(data: readonly PointDataItemType[]): readonly PointDataItemType[] { @@ -172,14 +189,24 @@ function transformXAxisOptions(axis?: CartesianChartProps.XAxisOptions): Cartesi return transformAxisOptions(axis); } -function transformYAxisOptions(axis?: CartesianChartProps.YAxisOptions): CartesianChartProps.YAxisOptions { - return { ...transformAxisOptions(axis), reversedStacks: axis?.reversedStacks }; +function transformYAxisOptions( + axis?: CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId], +): CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId] { + if (Array.isArray(axis)) { + return [transformSingleYAxisOptions(axis[0]), transformSingleYAxisOptions(axis[1])]; + } + return transformSingleYAxisOptions(axis); +} + +function transformSingleYAxisOptions(axis?: T): T { + return { ...transformAxisOptions(axis), reversedStacks: axis?.reversedStacks } as T; } function transformAxisOptions( axis?: O, ): O { return { + id: axis?.id, type: axis?.type, title: axis?.title, min: axis?.min, diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 79b32313..5e53ed5f 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -74,9 +74,13 @@ export interface CartesianChartProps /** * Defines options of the chart's y axis. - * This property corresponds to [xAxis](https://api.highcharts.com/highcharts/yAxis), and extends it + * This property corresponds to [yAxis](https://api.highcharts.com/highcharts/yAxis), and extends it * with a custom value formatter. * + * Use a single object for a single y axis, or a tuple of two objects for a dual-axis chart. + * When using a tuple, both axes must have an `id`. The second axis is automatically rendered on the opposite (right) side. + * Series reference their axis by setting `yAxis` to the axis `id`. + * * Supported options: * * `title` (optional, string) - Axis title. * * `type` (optional, "linear" | "datetime" | "category" | "logarithmic") - Axis type. @@ -92,7 +96,7 @@ export interface CartesianChartProps * * `valueFormatter` (optional, function) - Takes axis tick as input and returns a formatted string. This formatter also * applies to the tooltip points values. */ - yAxis?: CartesianChartProps.YAxisOptions; + yAxis?: CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]; /** * Specifies which series to show using their IDs. By default, all series are visible and managed by the component. @@ -141,6 +145,7 @@ export namespace CartesianChartProps { export type YThresholdSeriesOptions = CoreTypes.YThresholdSeriesOptions; interface AxisOptions { + id?: string; title?: string; type?: "linear" | "datetime" | "category" | "logarithmic"; min?: number; @@ -156,6 +161,10 @@ export namespace CartesianChartProps { reversedStacks?: boolean; } + export interface YAxisWithId extends YAxisOptions { + id: string; + } + export interface TooltipOptions { enabled?: boolean; placement?: "middle" | "outside"; diff --git a/src/core/__tests__/chart-core-legend.test.tsx b/src/core/__tests__/chart-core-legend.test.tsx index 194d66a6..b6511e0c 100644 --- a/src/core/__tests__/chart-core-legend.test.tsx +++ b/src/core/__tests__/chart-core-legend.test.tsx @@ -600,17 +600,17 @@ describe("CoreChart: legend", () => { describe("CoreChart: secondary legend", () => { test("does not render when no secondary axis series exist", () => { renderChart({ highcharts, options: { series: primarySeries, yAxis: yAxes } }); - expect(createChartWrapper().findSecondaryLegend()).toBe(null); + expect(createChartWrapper().findLegend({ axisId: "secondary" })).toBe(null); }); test("renders when only secondary axis series exist", () => { renderChart({ highcharts, options: { series: secondarySeries, yAxis: yAxes } }); - expect(createChartWrapper().findSecondaryLegend()).not.toBe(null); + expect(createChartWrapper().findLegend({ axisId: "secondary" })).not.toBe(null); }); test("renders when both primary and secondary axis series exist", () => { renderChart({ highcharts, options: { series: [...primarySeries, ...secondarySeries], yAxis: yAxes } }); - expect(createChartWrapper().findSecondaryLegend()).not.toBe(null); + expect(createChartWrapper().findLegend({ axisId: "secondary" })).not.toBe(null); }); test("renders no secondary legend when legend.enabled=false", () => { @@ -619,7 +619,7 @@ describe("CoreChart: secondary legend", () => { legend: { enabled: false }, options: { series: [...primarySeries, ...secondarySeries], yAxis: yAxes }, }); - expect(createChartWrapper().findSecondaryLegend()).toBe(null); + expect(createChartWrapper().findLegend({ axisId: "secondary" })).toBe(null); }); test("renders expected secondary legend items", () => { @@ -629,7 +629,7 @@ describe("CoreChart: secondary legend", () => { options: { series: [...primarySeries, ...secondarySeries], yAxis: yAxes }, }); - const items = createChartWrapper().findSecondaryLegend()!.findItems(); + const items = createChartWrapper().findLegend({ axisId: "secondary" })!.findItems(); expect(items.map((w) => w.getElement().textContent)).toEqual(["Secondary 1", "Secondary 2"]); }); @@ -640,7 +640,9 @@ describe("CoreChart: secondary legend", () => { options: { series: [...primarySeries, ...secondarySeries], yAxis: yAxes }, }); - expect(createChartWrapper().findSecondaryLegend()!.findTitle()!.getElement().textContent).toBe("Secondary Legend"); + expect(createChartWrapper().findLegend({ axisId: "secondary" })!.findTitle()!.getElement().textContent).toBe( + "Secondary Legend", + ); }); test("renders secondary legend actions if specified", () => { @@ -650,7 +652,7 @@ describe("CoreChart: secondary legend", () => { options: { series: [...primarySeries, ...secondarySeries], yAxis: yAxes }, }); - expect(createChartWrapper().findSecondaryLegend()!.findActions()!.getElement().textContent).toBe( + expect(createChartWrapper().findLegend({ axisId: "secondary" })!.findActions()!.getElement().textContent).toBe( "Secondary Actions", ); }); diff --git a/src/core/__tests__/common.tsx b/src/core/__tests__/common.tsx index f62236e2..66f1fc5c 100644 --- a/src/core/__tests__/common.tsx +++ b/src/core/__tests__/common.tsx @@ -100,16 +100,16 @@ export function leaveLegendItem(index: number, wrapper: BaseChartWrapper = creat } export function selectSecondaryLegendItem(index: number, wrapper: ExtendedTestWrapper = createChartWrapper()) { - act(() => wrapper.findSecondaryLegend()!.findItems()[index].click()); + act(() => wrapper.findLegend({ axisId: "secondary" })!.findItems()[index].click()); } export function toggleSecondaryLegendItem(index: number, wrapper: ExtendedTestWrapper = createChartWrapper()) { const modifier = Math.random() > 0.5 ? { metaKey: true } : { ctrlKey: true }; - act(() => wrapper.findSecondaryLegend()!.findItems()[index].click(modifier)); + act(() => wrapper.findLegend({ axisId: "secondary" })!.findItems()[index].click(modifier)); } export function hoverSecondaryLegendItem(index: number, wrapper: ExtendedTestWrapper = createChartWrapper()) { act(() => { - fireEvent.mouseOver(wrapper.findSecondaryLegend()!.findItems()[index].getElement()); + fireEvent.mouseOver(wrapper.findLegend({ axisId: "secondary" })!.findItems()[index].getElement()); }); } diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index 0e4f0547..254aadd5 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -235,7 +235,7 @@ export function InternalCoreChart({ // Depending on the chart.inverted the y-axis can be rendered as vertical, and needs to respect page direction. reversed: inverted && isRtl ? !yAxisOptions.reversed : yAxisOptions.reversed, opposite: !inverted && isRtl ? !yAxisOptions.opposite : yAxisOptions.opposite, - className: yAxisClassName(inverted, yAxisOptions.className), + className: yAxisClassName(inverted, yAxisOptions.className, yAxisOptions.id), title: axisTitle(yAxisOptions.title ?? {}, inverted || verticalAxisTitlePlacement === "side"), labels: axisLabels(yAxisOptions.labels ?? {}), plotLines: yAxisPlotLines(yAxisOptions.plotLines, emphasizeBaseline), @@ -408,11 +408,12 @@ function xAxisClassName(inverted: boolean, customClassName?: string) { ); } -function yAxisClassName(inverted: boolean, customClassName?: string) { +function yAxisClassName(inverted: boolean, customClassName?: string, axisId?: string) { return clsx( testClasses["axis-y"], inverted ? testClasses["axis-horizontal"] : testClasses["axis-vertical"], customClassName, + axisId && `awsui-axis-${axisId}`, ); } diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 5c1a5b28..928aef6a 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -244,7 +244,9 @@ export interface ErrorBarSeriesOptions extends Omit { type: "x-threshold"; value: number; } @@ -290,7 +292,9 @@ interface BaseSeriesOptions { color?: string; } -type BaseCartesianSeriesOptions = BaseSeriesOptions; +interface BaseCartesianSeriesOptions extends BaseSeriesOptions { + yAxis?: string; +} interface BaseCartesianLineLikeOptions extends BaseCartesianSeriesOptions { dashStyle?: Highcharts.DashStyleValue; diff --git a/src/core/utils.ts b/src/core/utils.ts index aac480f7..e41b257f 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -204,6 +204,9 @@ export function getVisibleLegendItems(options: Highcharts.Options) { const valueAxes = (isInverted ? castArray(options.xAxis) : castArray(options.yAxis)) ?? []; const defaultOpposite = valueAxes.length > 0 ? (valueAxes[0].opposite ?? false) : false; + const primaryAxis = valueAxes.find((a) => !(a.opposite ?? false)); + const secondaryAxis = valueAxes.find((a) => a.opposite === true); + const primaryItems: LegendItemOptions[] = []; const secondaryItems: LegendItemOptions[] = []; const addLegendItem = (item: LegendItemOptions) => { @@ -236,7 +239,7 @@ export function getVisibleLegendItems(options: Highcharts.Options) { } }); - return { primaryItems, secondaryItems }; + return { primaryItems, secondaryItems, primaryAxisId: primaryAxis?.id, secondaryAxisId: secondaryAxis?.id }; } function isSecondaryLegendItem( @@ -262,13 +265,14 @@ export function getLegendsProps( // While Highcharts supports more than two axes, this // implementation supports at most two, in which case one // of them must be set as opposite (secondary). - const { primaryItems, secondaryItems } = getVisibleLegendItems(options); + const { primaryItems, secondaryItems, primaryAxisId, secondaryAxisId } = getVisibleLegendItems(options); return { primary: primaryItems.length === 0 ? undefined : ({ isSecondary: false, + axisId: primaryAxisId, title: legendOptions?.title, actions: legendOptions?.actions, alignment: legendOptions?.position === "side" ? "vertical" : "horizontal", @@ -279,6 +283,7 @@ export function getLegendsProps( ? undefined : ({ isSecondary: true, + axisId: secondaryAxisId, title: legendOptions?.secondaryLegendTitle, actions: legendOptions?.secondaryLegendActions, alignment: legendOptions?.position === "side" ? "vertical" : "horizontal", diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index 9ae50040..67e89dd9 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -35,6 +35,7 @@ export interface ChartLegendProps { items: readonly LegendItem[]; legendTitle?: string; ariaLabel?: string; + axisId?: string; className?: string; actions?: React.ReactNode; someHighlighted: boolean; @@ -51,6 +52,7 @@ export const ChartLegend = ({ items, legendTitle, ariaLabel, + axisId, actions, alignment, className, @@ -223,6 +225,7 @@ export const ChartLegend = ({ role="toolbar" aria-label={legendTitle || ariaLabel} className={clsx(testClasses.root, styles.root, className)} + data-axisid={axisId} onMouseEnter={() => (isMouseInContainer.current = true)} onMouseLeave={() => (isMouseInContainer.current = false)} > diff --git a/src/test-utils/dom/internal/base.ts b/src/test-utils/dom/internal/base.ts index 46dd8523..b962e42b 100644 --- a/src/test-utils/dom/internal/base.ts +++ b/src/test-utils/dom/internal/base.ts @@ -20,9 +20,13 @@ export default class BaseChartWrapper extends ComponentWrapper { /** * Finds chart's legend when defined. + * @param axisId Optional axis ID to target a specific legend (e.g. "primary", "secondary"). */ - public findLegend(): null | BaseChartLegendWrapper { - return this.findComponent(`.${BaseChartLegendWrapper.rootSelector}`, BaseChartLegendWrapper); + public findLegend({ axisId }: { axisId?: string } = {}): null | BaseChartLegendWrapper { + const selector = axisId + ? `.${BaseChartLegendWrapper.rootSelector}[data-axisid="${axisId}"]` + : `.${BaseChartLegendWrapper.rootSelector}`; + return this.findComponent(selector, BaseChartLegendWrapper); } /** @@ -66,8 +70,12 @@ export default class BaseChartWrapper extends ComponentWrapper { /** * Finds visible title of the y axis. + * @param axisId Optional axis ID to target a specific y axis title (e.g. "secondary"). */ - public findYAxisTitle(): null | ElementWrapper { + public findYAxisTitle({ axisId }: { axisId?: string } = {}): null | ElementWrapper { + if (axisId) { + return this.find(`.highcharts-axis.awsui-axis-${axisId} > .highcharts-axis-title`); + } return ( this.findByClassName(testClasses["axis-y-title"]) ?? this.find(`.highcharts-axis.${testClasses["axis-y"]} > .highcharts-axis-title`) diff --git a/src/test-utils/dom/internal/core.ts b/src/test-utils/dom/internal/core.ts index 9861408e..c14f4f6b 100644 --- a/src/test-utils/dom/internal/core.ts +++ b/src/test-utils/dom/internal/core.ts @@ -21,18 +21,11 @@ export default class CoreChartWrapper extends BaseChartWrapper { return this.findByClassName(testClasses["chart-navigator"]); } - public findLegend(): null | CoreChartLegendWrapper { - return this.findComponent( - `.${CoreChartLegendWrapper.rootSelector}.${testClasses["legend-primary"]}`, - CoreChartLegendWrapper, - ); - } - - public findSecondaryLegend(): null | CoreChartLegendWrapper { - return this.findComponent( - `.${CoreChartLegendWrapper.rootSelector}.${testClasses["legend-secondary"]}`, - CoreChartLegendWrapper, - ); + public findLegend({ axisId }: { axisId?: string } = {}): null | CoreChartLegendWrapper { + const selector = axisId + ? `.${CoreChartLegendWrapper.rootSelector}[data-axisid="${axisId}"]` + : `.${CoreChartLegendWrapper.rootSelector}.${testClasses["legend-primary"]}`; + return this.findComponent(selector, CoreChartLegendWrapper); } public findVerticalAxisTitle(): null | ElementWrapper {