- {api.renderMarker(getSeriesMarkerType(point.series), getPointColor(point))}
+ {api.renderMarker(getSeriesMarkerType(point.series), getPointColor(point), true, status, markerAriaLabel)}
{point.name}
diff --git a/src/core/i18n-utils.tsx b/src/core/i18n-utils.tsx
index 08f38ce5..70a225fd 100644
--- a/src/core/i18n-utils.tsx
+++ b/src/core/i18n-utils.tsx
@@ -22,7 +22,7 @@ export function useChartI18n({
ariaLabel?: string;
ariaDescription?: string;
i18nStrings?: CoreChartProps.I18nStrings;
-}) {
+}): ChartLabels {
const i18n = useInternalI18n("[charts]");
const i18nPie = useInternalI18n("pie-chart");
return {
diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts
index fd2c2a0e..0bb8e77e 100644
--- a/src/core/interfaces.ts
+++ b/src/core/interfaces.ts
@@ -4,6 +4,7 @@
import type Highcharts from "highcharts";
import type * as InternalComponentTypes from "../internal/components/interfaces";
+import { ChartSeriesMarkerStatus } from "../internal/components/series-marker/interfaces";
import { type NonCancelableEventHandler } from "../internal/events";
// All charts take `highcharts` instance, that can be served statically or dynamically.
@@ -135,6 +136,13 @@ export interface CoreI18nStrings extends BaseI18nStrings {
* this property to be explicitly provided.
*/
secondaryLegendAriaLabel?: string;
+
+ /**
+ * ARIA label for the marker of a series.
+ * @param status the status of the series.
+ * @note series with status "default" will not announce a label, thus excluded by this method.
+ */
+ itemMarkerAriaLabel?: (status: Exclude
) => string;
}
export interface WithCartesianI18nStrings {
@@ -398,6 +406,10 @@ export interface CoreChartProps
* @i18n
*/
i18nStrings?: CartesianI18nStrings & PieI18nStrings & CoreI18nStrings;
+ /**
+ * Specifies the options for each item in the chart.
+ */
+ getItemOptions?: CoreChartProps.GetItemOptions;
}
export namespace CoreChartProps {
@@ -413,6 +425,8 @@ export namespace CoreChartProps {
clearChartHighlight(): void;
}
+ export type GetItemOptions = (props: CoreChartProps.GetItemOptionsProps) => CoreChartProps.ChartItemOptions;
+
// The extended version of Highcharts.Options. The axes types are extended with Cloudscape value formatter.
// We use a custom formatter because we cannot use the built-in Highcharts formatter for our tooltip.
export type ChartOptions = Omit & {
@@ -422,6 +436,22 @@ export namespace CoreChartProps {
export type XAxisOptions = Highcharts.XAxisOptions & { valueFormatter?: (value: null | number) => string };
export type YAxisOptions = Highcharts.YAxisOptions & { valueFormatter?: (value: null | number) => string };
+ export interface ChartItemOptions {
+ /**
+ * If specified, specifies the status of an item.
+ * An item can be a point or a series.
+ * @default "default"
+ */
+ status?: ChartSeriesMarkerStatus;
+ }
+
+ export interface GetItemOptionsProps {
+ /**
+ * The item id. Can be the id of a series or a point.
+ */
+ itemId: string;
+ }
+
export interface HeaderOptions {
content: React.ReactNode;
}
diff --git a/src/core/utils.ts b/src/core/utils.ts
index c934990f..439d3159 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -4,11 +4,12 @@
import type Highcharts from "highcharts";
import { ChartSeriesMarkerType } from "../internal/components/series-marker";
+import { ChartSeriesMarkerStatus } from "../internal/components/series-marker/interfaces";
import { getChartSeries } from "../internal/utils/chart-series";
import { castArray } from "../internal/utils/utils";
import { getFormatter } from "./formatters";
import { ChartLabels } from "./i18n-utils";
-import { CoreChartProps, Rect } from "./interfaces";
+import { CoreChartProps, CoreI18nStrings, Rect } from "./interfaces";
export interface LegendItemSpec {
id: string;
@@ -17,6 +18,8 @@ export interface LegendItemSpec {
color: string;
visible: boolean;
isSecondary: boolean;
+ status?: ChartSeriesMarkerStatus;
+ markerAriaLabel?: string;
}
// The below functions extract unique identifier from series, point, or options. The identifier can be item's ID or name.
@@ -132,7 +135,11 @@ export function getPointColor(point?: Highcharts.Point): string {
// There exists a Highcharts APIs to access legend items, but it is unfortunately not available, when
// Highcharts legend is disabled. Instead, we use this custom method to collect legend items from the chart.
-export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendItemSpec[] {
+export function getChartLegendItems(
+ chart: Highcharts.Chart,
+ getItemOptions: CoreChartProps.GetItemOptions,
+ itemMarkerAriaLabel: CoreI18nStrings["itemMarkerAriaLabel"],
+): readonly LegendItemSpec[] {
const legendItems: LegendItemSpec[] = [];
const isInverted = chart.inverted ?? false;
const addSeriesItem = (series: Highcharts.Series, isSecondary: boolean) => {
@@ -148,24 +155,31 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte
// We respect Highcharts showInLegend option to allow hiding certain series from the legend.
// The same is not supported for pie chart segments.
if (series.options.showInLegend !== false) {
+ const seriesId = getSeriesId(series);
+ const itemProps = getItemOptions({ itemId: seriesId });
+ const status = itemProps.status;
legendItems.push({
- id: getSeriesId(series),
+ id: seriesId,
name: series.name,
markerType: getSeriesMarkerType(series),
color: getSeriesColor(series),
visible: series.visible,
isSecondary,
+ status: status,
+ markerAriaLabel: status !== undefined && status !== "default" ? itemMarkerAriaLabel?.(status) : undefined,
});
}
};
const addPointItem = (point: Highcharts.Point, isSecondary: boolean) => {
if (point?.series?.type === "pie") {
+ const pointId = getPointId(point);
legendItems.push({
- id: getPointId(point),
+ id: pointId,
name: point.name,
markerType: getSeriesMarkerType(point.series),
color: getPointColor(point),
visible: point.visible,
+ status: getItemOptions({ itemId: pointId }).status,
isSecondary,
});
}
diff --git a/src/internal/components/chart-legend/styles.scss b/src/internal/components/chart-legend/styles.scss
index 7c063da9..fffb17ae 100644
--- a/src/internal/components/chart-legend/styles.scss
+++ b/src/internal/components/chart-legend/styles.scss
@@ -38,7 +38,7 @@
.list-bottom {
flex-wrap: wrap;
- gap: cs.$space-scaled-xxs cs.$space-static-m;
+ gap: cs.$space-scaled-xxs cs.$space-static-xl;
&.list-bottom-start {
justify-content: start;
diff --git a/src/internal/components/series-marker/__tests__/index.test.tsx b/src/internal/components/series-marker/__tests__/index.test.tsx
new file mode 100644
index 00000000..e377cd89
--- /dev/null
+++ b/src/internal/components/series-marker/__tests__/index.test.tsx
@@ -0,0 +1,40 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { ChartSeriesMarker } from "@lib/components/internal/components/series-marker";
+import { ChartSeriesMarkerProps } from "@lib/components/internal/components/series-marker";
+import { render } from "@testing-library/react";
+import { describe, expect, test } from "vitest";
+
+describe("ChartSeriesMarker", () => {
+ const defaultProps = {
+ type: "line",
+ color: "#0073bb",
+ ariaLabel: "This is a description",
+ } satisfies ChartSeriesMarkerProps;
+
+ describe("Warning SVG display", () => {
+ test("does not render warning SVG when status is undefined", () => {
+ const { container } = render();
+ const svgs = container.querySelector("path[data-testid='warning']");
+
+ // Should only have one SVG (the marker itself)
+ expect(svgs).toBeFalsy();
+ });
+
+ test("renders warning SVG when status is 'warning'", () => {
+ const { container } = render();
+ const svgs = container.querySelector("path[data-testid='warning']");
+
+ // Should have two SVGs: marker + warning
+ expect(svgs).toBeTruthy();
+ });
+
+ test("renders aria description when provided", () => {
+ const { container } = render();
+ const span = container.querySelector("span");
+
+ expect(span!.textContent).toBe("This is a description");
+ });
+ });
+});
diff --git a/src/internal/components/series-marker/index.tsx b/src/internal/components/series-marker/index.tsx
index d9e050fb..a141f158 100644
--- a/src/internal/components/series-marker/index.tsx
+++ b/src/internal/components/series-marker/index.tsx
@@ -1,8 +1,11 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+import { useUniqueId } from "@cloudscape-design/component-toolkit/internal";
import { BaseComponentProps } from "@cloudscape-design/components/internal/base-component";
-import { colorTextInteractiveDisabled } from "@cloudscape-design/design-tokens";
+import { colorBorderStatusWarning, colorTextInteractiveDisabled } from "@cloudscape-design/design-tokens";
+
+import { ChartSeriesMarkerStatus } from "./interfaces";
import styles from "./styles.css.js";
@@ -21,83 +24,130 @@ export interface ChartSeriesMarkerProps extends BaseComponentProps {
type: ChartSeriesMarkerType;
color: string;
visible?: boolean;
+ status?: ChartSeriesMarkerStatus;
+ ariaLabel?: string;
}
function scale(size: number, value: number) {
return `translate(${size * ((1 - value) / 2)}, ${size * ((1 - value) / 2)}) scale(${value})`;
}
-export function ChartSeriesMarker({ type = "line", color, visible = true }: ChartSeriesMarkerProps) {
+export function ChartSeriesMarker({
+ type = "line",
+ color,
+ visible = true,
+ status = "default",
+ ariaLabel,
+}: ChartSeriesMarkerProps) {
color = visible ? color : colorTextInteractiveDisabled;
+
+ // As React re-renders the components, a new ID should be created for masks.
+ // Not doing so will not evaluate the mask.
+ const maskId = useUniqueId("chart-series-marker");
+
return (
-
);
}
-function SVGLine({ color }: { color: string }) {
- return ;
+function SVGLine({ color, maskId }: { color: string; maskId: string }) {
+ return ;
}
-function SVGLineDashed({ color }: { color: string }) {
+function SVGLineDashed({ color, maskId }: { color: string; maskId: string }) {
return (
<>
-
-
+
+
>
);
}
-function SVGLargeSquare({ color }: { color: string }) {
+function SVGLargeSquare({ color, maskId }: { color: string; maskId: string }) {
const shape = { x: 0, y: 0, width: 16, height: 16, rx: 2, transform: scale(16, 0.85) };
- return ;
+ return ;
}
-function SVGHollowSquare({ color }: { color: string }) {
+function SVGHollowSquare({ color, maskId }: { color: string; maskId: string }) {
const size = { x: 0, y: 0, width: 16, height: 16, rx: 2, transform: scale(16, 0.75) };
- return ;
+ return ;
}
-function SVGSquare({ color }: { color: string }) {
+function SVGSquare({ color, maskId }: { color: string; maskId: string }) {
const size = { x: 3, y: 3, width: 10, height: 10, rx: 2 };
- return ;
+ return ;
}
-function SVGDiamond({ color }: { color: string }) {
+function SVGDiamond({ color, maskId }: { color: string; maskId: string }) {
const shape = { points: "8,0 16,8 8,16 0,8", transform: scale(16, 0.65) };
- return ;
+ return ;
}
-function SVGTriangle({ color }: { color: string }) {
+function SVGTriangle({ color, maskId }: { color: string; maskId: string }) {
const shape = { points: "8,0 16,16 0,16", transform: scale(16, 0.65) };
- return ;
+ return ;
}
-function SVGTriangleDown({ color }: { color: string }) {
+function SVGTriangleDown({ color, maskId }: { color: string; maskId: string }) {
const shape = { points: "8,16 0,0 16,0", transform: scale(16, 0.65) };
- return ;
+ return ;
}
-function SVGCircle({ color }: { color: string }) {
+function SVGCircle({ color, maskId }: { color: string; maskId: string }) {
const shape = { cx: 8, cy: 8, r: 5 };
- return ;
+ return ;
+}
+
+const SVGWarningTranslate = `translate(10, 1)`;
+
+function SVGWarning() {
+ return (
+
+ );
+}
+
+function SVGWarningMask() {
+ return (
+
+ );
}
diff --git a/src/internal/components/series-marker/interfaces.ts b/src/internal/components/series-marker/interfaces.ts
index d4037430..12c2a626 100644
--- a/src/internal/components/series-marker/interfaces.ts
+++ b/src/internal/components/series-marker/interfaces.ts
@@ -11,3 +11,5 @@ export type ChartSeriesMarkerType =
| "triangle"
| "triangle-down"
| "circle";
+
+export type ChartSeriesMarkerStatus = "warning" | "default";
diff --git a/src/internal/components/series-marker/styles.scss b/src/internal/components/series-marker/styles.scss
index de66b1e8..b599ea0d 100644
--- a/src/internal/components/series-marker/styles.scss
+++ b/src/internal/components/series-marker/styles.scss
@@ -13,13 +13,18 @@ $marker-margin-right: cs.$space-static-xxs;
.marker {
@include styles.styles-reset;
margin-block-start: $marker-margin-top;
- margin-inline-end: $marker-margin-right;
border-start-start-radius: 2px;
border-start-end-radius: 2px;
border-end-start-radius: 2px;
border-end-end-radius: 2px;
- inline-size: $marker-size;
+ inline-size: 25px;
block-size: $marker-size;
flex-shrink: 0;
cursor: inherit;
}
+
+.visually-hidden {
+ position: absolute !important;
+ inset-block-start: -9999px !important;
+ inset-inline-start: -9999px !important;
+}