diff --git a/pages/03-core/core-legend.page.tsx b/pages/03-core/core-legend.page.tsx index fcae7387..d5376d1b 100644 --- a/pages/03-core/core-legend.page.tsx +++ b/pages/03-core/core-legend.page.tsx @@ -105,21 +105,21 @@ const initialLegendItems: readonly LegendItem[] = [ { id: "CPU Utilization", name: "CPU Utilization", - marker: , + marker: , visible: true, highlighted: false, }, { id: "Memory Usage", name: "Memory Usage", - marker: , + marker: , visible: true, highlighted: false, }, { id: "Storage Capacity", name: "Storage Capacity", - marker: , + marker: , visible: true, highlighted: false, }, @@ -180,7 +180,14 @@ export default function () { ...item, visible, highlighted: visible, - marker: , + marker: ( + + ), }; }); }); diff --git a/pages/03-core/core-line-chart.page.tsx b/pages/03-core/core-line-chart.page.tsx index 0cefd9d5..9c4f1a8b 100644 --- a/pages/03-core/core-line-chart.page.tsx +++ b/pages/03-core/core-line-chart.page.tsx @@ -69,6 +69,7 @@ const series: Highcharts.SeriesOptionsType[] = [ data: dataB, }, { + id: "A", name: "Comprehensive System Resource Utilization Measurements Over Time", type: "line", data: dataC, @@ -105,6 +106,14 @@ export default function () { { + if (status === "warning") { + return "Warning status"; + } + return status as never; + }, + }} options={{ lang: { accessibility: { @@ -126,6 +135,9 @@ export default function () { }, }, }} + getItemOptions={({ itemId }) => ({ + status: itemId === "A" ? "warning" : "default", + })} chartHeight={400} getTooltipContent={() => ({ point({ item, hideTooltip }) { diff --git a/pages/03-core/events-sync-demo.page.tsx b/pages/03-core/events-sync-demo.page.tsx index 15f4f762..17c8b06a 100644 --- a/pages/03-core/events-sync-demo.page.tsx +++ b/pages/03-core/events-sync-demo.page.tsx @@ -113,7 +113,7 @@ export default function LegendEventsDemo() { const legendItems: LegendItem[] = series.map((s) => ({ id: s.name, name: s.name, - marker: , + marker: , visible: visibleItems.has(s.name), highlighted: highlightedItem === s.name, })); diff --git a/pages/03-core/marker-permutations.page.tsx b/pages/03-core/marker-permutations.page.tsx index 50f7f75e..2ef9eca2 100644 --- a/pages/03-core/marker-permutations.page.tsx +++ b/pages/03-core/marker-permutations.page.tsx @@ -21,7 +21,7 @@ import { import { ChartSeriesMarker, ChartSeriesMarkerProps } from "../../lib/components/internal/components/series-marker"; import PermutationsView, { createPermutations } from "../common/permutations"; -import { Page } from "../common/templates"; +import { Page, PageSection } from "../common/templates"; const permutationsForColors = [ colorChartsPaletteCategorical1, @@ -53,10 +53,16 @@ const permutationsForColors = [ "triangle-down", ], color: [color], + ariaLabel: ["aria"], + status: ["default"], }, ]), ); +const permutationsForWarningColors = permutationsForColors.map((permutations) => + permutations.map((permutation) => ({ ...permutation, status: "warning" as const })), +); + export default function MarkerPermutations() { return ( @@ -70,6 +76,19 @@ export default function MarkerPermutations() { /> ))} + + + + {permutationsForWarningColors.map((permutations, index) => ( + } + direction="horizontal" + /> + ))} + + ); } diff --git a/pages/06-visual-tests/cartesian-tooltip.page.tsx b/pages/06-visual-tests/cartesian-tooltip.page.tsx index 90cbe370..59864318 100644 --- a/pages/06-visual-tests/cartesian-tooltip.page.tsx +++ b/pages/06-visual-tests/cartesian-tooltip.page.tsx @@ -31,7 +31,7 @@ const dataA = baseline.map(({ x, y }) => ({ x, y })); const dataB = baseline.map(({ x, y }, index) => ({ x, y: y + index * 10000 })); const series: Highcharts.SeriesOptionsType[] = [ - { name: "A", type: "spline", data: dataA }, + { id: "A", name: "A", type: "spline", data: dataA }, { name: "B", type: "spline", data: dataB }, ]; @@ -49,6 +49,9 @@ export default function () { }} chartHeight={400} tooltip={{ placement: "outside" }} + getItemOptions={({ itemId }) => ({ + status: itemId === "A" ? "warning" : "default", + })} getTooltipContent={() => ({ footer() { return ; diff --git a/pages/06-visual-tests/column-hover.page.tsx b/pages/06-visual-tests/column-hover.page.tsx index df86d289..3b6807c5 100644 --- a/pages/06-visual-tests/column-hover.page.tsx +++ b/pages/06-visual-tests/column-hover.page.tsx @@ -46,6 +46,7 @@ function Chart({ type }: { type: "single" | "stacked" | "grouped" }) { plotOptions: { series: { stacking: type === "stacked" ? "normal" : undefined } }, series: [ { + id: "Severe", name: "Severe", type: "column" as const, data: [22, 28, 25, 13, 28], @@ -75,6 +76,9 @@ function Chart({ type }: { type: "single" | "stacked" | "grouped" }) { ], yAxis: [{ title: { text: "Error count" } }], }} + getItemOptions={({ itemId }) => ({ + status: itemId === "Severe" ? "warning" : "default", + })} callback={(api) => { setTimeout(() => { if (api.chart.series) { diff --git a/pages/06-visual-tests/pie-tooltip.page.tsx b/pages/06-visual-tests/pie-tooltip.page.tsx index 43a1cad5..219510a7 100644 --- a/pages/06-visual-tests/pie-tooltip.page.tsx +++ b/pages/06-visual-tests/pie-tooltip.page.tsx @@ -15,7 +15,7 @@ const series: Highcharts.SeriesOptionsType[] = [ type: "pie", data: [ { name: "Running", y: 60 }, - { name: "Failed", y: 30 }, + { name: "Failed", y: 30, id: "Failed" }, { name: "In-progress", y: 10 }, ], }, @@ -31,6 +31,9 @@ export default function () { options={{ series: series, }} + getItemOptions={({ itemId }) => ({ + status: itemId === "Failed" ? "warning" : "default", + })} chartHeight={400} getTooltipContent={() => ({ footer() { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 65578760..c4057ff2 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -1452,6 +1452,29 @@ minimum width, the horizontal scrollbar is automatically added.", "type": "CoreChartProps.FooterOptions", "visualRefreshTag": undefined, }, + { + "analyticsTag": undefined, + "defaultValue": undefined, + "deprecatedTag": undefined, + "description": "Specifies the options for each item in the chart.", + "i18nTag": undefined, + "inlineType": { + "name": "CoreChartProps.GetItemOptions", + "parameters": [ + { + "name": "props", + "type": "CoreChartProps.GetItemOptionsProps", + }, + ], + "returnType": "CoreChartProps.ChartItemOptions", + "type": "function", + }, + "name": "getItemOptions", + "optional": true, + "systemTags": undefined, + "type": "CoreChartProps.GetItemOptions", + "visualRefreshTag": undefined, + }, { "analyticsTag": undefined, "defaultValue": undefined, diff --git a/src/core/__tests__/chart-core-legend.test.tsx b/src/core/__tests__/chart-core-legend.test.tsx index b435263d..3bf15b14 100644 --- a/src/core/__tests__/chart-core-legend.test.tsx +++ b/src/core/__tests__/chart-core-legend.test.tsx @@ -3,10 +3,11 @@ import { act } from "react"; import highcharts from "highcharts"; -import { vi } from "vitest"; +import { describe, vi } from "vitest"; import { KeyCode } from "@cloudscape-design/component-toolkit/internal"; +import * as seriesMarker from "../../../lib/components/internal/components/series-marker"; import { createChartWrapper, hoverLegendItem, @@ -93,6 +94,8 @@ const mouseOver = (element: HTMLElement) => element.dispatchEvent(new MouseEvent const mouseOut = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); const mouseLeavePause = () => new Promise((resolve) => setTimeout(resolve, 300)); +vi.mock(import("../../../lib/components/internal/components/series-marker"), { spy: true }); + describe("CoreChart: legend", () => { test("renders no legend when legend.enabled=false", () => { renderChart({ highcharts, options: { series }, legend: { enabled: false } }); @@ -527,6 +530,49 @@ describe("CoreChart: legend", () => { rerender({ highcharts, options: { series: lineSeries.filter((s) => s.name !== "L1") } }); expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L2", "Line 3"]); }); + + describe("Marker status", () => { + const seriesMarkerMock = vi.mocked(seriesMarker.ChartSeriesMarker); + + beforeEach(() => { + seriesMarkerMock.mockImplementation((props) => { + return
; + }); + }); + afterEach(() => { + seriesMarkerMock.mockReset(); + }); + + test("should render markers using the corresponding status", () => { + const { wrapper } = renderChart({ + highcharts, + options: { + series: [ + { + id: "L1", + type: "line", + name: "L1", + data: [1], + }, + { + id: "L2", + type: "line", + name: "L2", + data: [1], + }, + ], + }, + getItemOptions: ({ itemId }) => ({ + status: itemId === "L1" ? "warning" : "default", + }), + }); + + const warnings = wrapper.findAll('[data-testid="warning"]'); + const defaults = wrapper.findAll('[data-testid="default"]'); + expect(warnings).toHaveLength(1); + expect(defaults).toHaveLength(1); + }); + }); }); describe("CoreChart: secondary legend", () => { diff --git a/src/core/__tests__/chart-core-utils.test.tsx b/src/core/__tests__/chart-core-utils.test.tsx index 4f90854f..e7f16ab4 100644 --- a/src/core/__tests__/chart-core-utils.test.tsx +++ b/src/core/__tests__/chart-core-utils.test.tsx @@ -79,7 +79,7 @@ describe("CoreChart: utils", () => { callback: (api) => (chartApi = api), }); - const items = getChartLegendItems(chartApi!.chart); + const items = getChartLegendItems(chartApi!.chart, () => ({}), undefined); expect(items[0].isSecondary).toBe(axisOptions.opposite); }, ); @@ -109,7 +109,7 @@ describe("CoreChart: utils", () => { callback: (api) => (chartApi = api), }); - const items = getChartLegendItems(chartApi!.chart); + const items = getChartLegendItems(chartApi!.chart, () => ({}), undefined); expect(items).toHaveLength(2); expect(items[0].isSecondary).toBe(false); expect(items[1].isSecondary).toBe(true); @@ -137,7 +137,7 @@ describe("CoreChart: utils", () => { callback: (api) => (chartApi = api), }); - const items = getChartLegendItems(chartApi!.chart); + const items = getChartLegendItems(chartApi!.chart, () => ({}), undefined); if (type === "gauge" || type === "solidgauge") { expect(items).toHaveLength(1); diff --git a/src/core/chart-api/chart-extra-context.tsx b/src/core/chart-api/chart-extra-context.tsx index f1fd257d..15bf4a0a 100644 --- a/src/core/chart-api/chart-extra-context.tsx +++ b/src/core/chart-api/chart-extra-context.tsx @@ -7,7 +7,7 @@ import { NonCancelableEventHandler } from "../../internal/events"; import { getChartSeries } from "../../internal/utils/chart-series"; import { getSeriesData } from "../../internal/utils/series-data"; import { ChartLabels } from "../i18n-utils"; -import { CoreChartProps, Rect } from "../interfaces"; +import { CoreChartProps, CoreI18nStrings, Rect } from "../interfaces"; import { getGroupRect, isSeriesStacked } from "../utils"; // Chart API context is used for dependency injection for chart utilities. @@ -32,6 +32,8 @@ export namespace ChartExtraContext { tooltipEnabled: boolean; keyboardNavigationEnabled: boolean; labels: ChartLabels; + getItemOptions: CoreChartProps.GetItemOptions; + itemMarkerAriaLabel: CoreI18nStrings["itemMarkerAriaLabel"]; } export interface Handlers { @@ -63,6 +65,8 @@ export function createChartContext(): ChartExtraContext { tooltipEnabled: false, keyboardNavigationEnabled: false, labels: {}, + getItemOptions: () => ({}), + itemMarkerAriaLabel: () => "", }, handlers: {}, state: {}, diff --git a/src/core/chart-api/chart-extra-legend.tsx b/src/core/chart-api/chart-extra-legend.tsx index a962a943..d8728336 100644 --- a/src/core/chart-api/chart-extra-legend.tsx +++ b/src/core/chart-api/chart-extra-legend.tsx @@ -5,6 +5,7 @@ import type Highcharts from "highcharts"; import { LegendItem } from "../../internal/components/interfaces"; import { ChartSeriesMarker, ChartSeriesMarkerType } from "../../internal/components/series-marker"; +import { ChartSeriesMarkerStatus } from "../../internal/components/series-marker/interfaces"; import { fireNonCancelableEvent } from "../../internal/events"; import AsyncStore from "../../internal/utils/async-store"; import { getChartSeries } from "../../internal/utils/chart-series"; @@ -83,11 +84,17 @@ export class ChartExtraLegend extends AsyncStore { private initLegend = () => { const prevState = this.get().items.reduce((map, item) => map.set(item.id, item), new Map()); - const itemSpecs = getChartLegendItems(this.context.chart()); - const legendItems = itemSpecs.map(({ id, name, color, markerType, visible, isSecondary }) => { - const marker = this.renderMarker(markerType, color, visible); - return { id, name, marker, visible, isSecondary, highlighted: prevState.get(id)?.highlighted ?? false }; - }); + const itemSpecs = getChartLegendItems( + this.context.chart(), + this.context.settings.getItemOptions, + this.context.settings.itemMarkerAriaLabel, + ); + const legendItems = itemSpecs.map( + ({ id, name, color, markerType, visible, status, isSecondary, markerAriaLabel }) => { + const marker = this.renderMarker(markerType, color, visible, status, markerAriaLabel); + return { id, name, marker, visible, isSecondary, highlighted: prevState.get(id)?.highlighted ?? false }; + }, + ); this.updateLegendItems(legendItems); }; @@ -109,9 +116,17 @@ export class ChartExtraLegend extends AsyncStore { // The chart markers derive from type and color and are cached to avoid unnecessary renders, // and allow comparing them by reference. private markersCache = new Map(); - public renderMarker(type: ChartSeriesMarkerType, color: string, visible = true): React.ReactNode { - const key = `${type}:${color}:${visible}`; - const marker = this.markersCache.get(key) ?? ; + public renderMarker( + type: ChartSeriesMarkerType, + color: string, + visible = true, + status?: ChartSeriesMarkerStatus, + ariaLabel?: string, + ): React.ReactNode { + const key = `${type}:${color}:${visible}:${status}`; + const marker = this.markersCache.get(key) ?? ( + + ); this.markersCache.set(key, marker); return marker; } diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index c7a62722..cae3e002 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -15,6 +15,7 @@ import { InternalBaseComponentProps } from "../internal/base-component/use-base- import * as Styles from "../internal/chart-styles"; import { castArray } from "../internal/utils/utils"; import { useChartAPI } from "./chart-api"; +import { ChartExtraContext } from "./chart-api/chart-extra-context"; import { ChartContainer } from "./chart-container"; import { ChartApplication } from "./components/core-application"; import { ChartFilters } from "./components/core-filters"; @@ -63,17 +64,20 @@ export function InternalCoreChart({ onVisibleItemsChange, visibleItems, __internalRootRef, + getItemOptions, ...rest }: CoreChartProps & InternalBaseComponentProps) { const highcharts = rest.highcharts as null | typeof Highcharts; const labels = useChartI18n({ ariaLabel, ariaDescription, i18nStrings }); - const context = { + const context: ChartExtraContext["settings"] = { chartId: useUniqueId(), noDataEnabled: !!noDataOptions, legendEnabled: legendOptions?.enabled !== false, tooltipEnabled: tooltipOptions?.enabled !== false, keyboardNavigationEnabled: keyboardNavigation, labels, + getItemOptions: getItemOptions ?? (() => ({})), + itemMarkerAriaLabel: i18nStrings?.itemMarkerAriaLabel, }; const handlers = { onHighlight, onClearHighlight, onVisibleItemsChange }; const state = { visibleItems }; diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index e67cd2a5..1e0a9e3b 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -15,7 +15,7 @@ import { useDebouncedValue } from "../../internal/utils/use-debounced-value"; import { ChartAPI } from "../chart-api"; import { getFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; -import { getPointColor, getSeriesColor, getSeriesId, getSeriesMarkerType, isXThreshold } from "../utils"; +import { getPointColor, getPointId, getSeriesColor, getSeriesId, getSeriesMarkerType, isXThreshold } from "../utils"; import styles from "../styles.css.js"; @@ -166,8 +166,16 @@ function getTooltipContentCartesian( // By design, every point of the group has the same x value. const x = group[0].x; const chart = group[0].series.chart; - const getSeriesMarker = (series: Highcharts.Series) => - api.renderMarker(getSeriesMarkerType(series), getSeriesColor(series), true); + const getSeriesMarker = (series: Highcharts.Series) => { + const { status } = api.context.settings.getItemOptions({ itemId: getSeriesId(series) }); + return api.renderMarker( + getSeriesMarkerType(series), + getSeriesColor(series), + true, + status, + status !== undefined && status !== "default" ? api.context.settings.itemMarkerAriaLabel?.(status) : undefined, + ); + }; const matchedItems = findTooltipSeriesItems(getChartSeries(chart.series), group, seriesSorting); const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => { const valueFormatter = getFormatter(item.point.series.yAxis); @@ -185,6 +193,7 @@ function getTooltipContentCartesian( subItems: customContent?.subItems, expandableId: customContent?.expandable ? item.point.series.name : undefined, highlighted: item.point.x === point?.x && item.point.y === point?.y, + itemAriaLabel: "awd", description: customContent?.description === undefined && item.errorRanges.length ? ( <> @@ -245,10 +254,15 @@ function getTooltipContentPie( items: [{ point, errorRanges: [] }], hideTooltip, }; + + const { status } = api.context.settings.getItemOptions({ itemId: getPointId(point) }); + const markerAriaLabel = + status !== undefined && status !== "default" ? api.context.settings.itemMarkerAriaLabel?.(status) : undefined; + return { header: renderers.header?.(tooltipDetails) ?? (
- {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 (
- - {type === "line" && } + {ariaLabel && {ariaLabel}} + + + + + + {status === "warning" && } + + - {type === "dashed" && } + {status === "warning" && } - {type === "large-square" && } + {type === "line" && } - {type === "hollow-square" && } + {type === "dashed" && } - {type === "square" && } + {type === "large-square" && } - {type === "diamond" && } + {type === "hollow-square" && } - {type === "triangle" && } + {type === "square" && } - {type === "triangle-down" && } + {type === "diamond" && } - {type === "circle" && } + {type === "triangle" && } + + {type === "triangle-down" && } + + {type === "circle" && }
); } -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; +}