diff --git a/api-goldens/charts-ng/common/index.api.md b/api-goldens/charts-ng/common/index.api.md index 79a6201ebf..eafc76c1f2 100644 --- a/api-goldens/charts-ng/common/index.api.md +++ b/api-goldens/charts-ng/common/index.api.md @@ -233,6 +233,7 @@ export interface SeriesUpdate { // @public (undocumented) export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, OnDestroy { readonly additionalOptions: _angular_core.InputSignal; + readonly ariaLabel: _angular_core.InputSignal; readonly autoZoomSeriesIndex: _angular_core.InputSignal; readonly axisPointer: _angular_core.InputSignal; readonly chartContainerWrapper: _angular_core.Signal>; diff --git a/api-goldens/charts-ng/index.api.md b/api-goldens/charts-ng/index.api.md index ccae64b446..61b85eb3b2 100644 --- a/api-goldens/charts-ng/index.api.md +++ b/api-goldens/charts-ng/index.api.md @@ -418,6 +418,7 @@ export { SiCandlestickSeriesOption as SimplCandlestickSeriesOption } // @public (undocumented) export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, OnDestroy { readonly additionalOptions: i0.InputSignal; + readonly ariaLabel: i0.InputSignal; readonly autoZoomSeriesIndex: i0.InputSignal; readonly axisPointer: i0.InputSignal; readonly chartContainerWrapper: i0.Signal>; diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--mobile.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--mobile.yaml index bac10d3c17..5a7ebaa756 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--mobile.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--mobile.yaml @@ -122,4 +122,4 @@ - text: Fri - img "Fri" - 'img /Wind: [\d,.]+[bkmBKM]+\/h/' - - text: /\d+° \d+°/ \ No newline at end of file + - text: /\d+° \d+°/ diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--normal.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--normal.yaml index 8bcae93af1..7fcf612c68 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--normal.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--normal.yaml @@ -124,4 +124,4 @@ - text: Fri - img "Fri" - 'img /Wind: [\d,.]+[bkmBKM]+\/h/' - - text: /\d+° \d+°/ \ No newline at end of file + - text: /\d+° \d+°/ diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--tablet.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--tablet.yaml index 08fcde35ff..760816c117 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--tablet.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--tablet.yaml @@ -119,4 +119,4 @@ - listitem: - text: Fri - img "Fri" - - text: /\d+° \d+°/ \ No newline at end of file + - text: /\d+° \d+°/ diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-preplaced.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-preplaced.yaml index 8bcae93af1..7fcf612c68 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-preplaced.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-preplaced.yaml @@ -124,4 +124,4 @@ - text: Fri - img "Fri" - 'img /Wind: [\d,.]+[bkmBKM]+\/h/' - - text: /\d+° \d+°/ \ No newline at end of file + - text: /\d+° \d+°/ diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-tablet.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-tablet.yaml index 08fcde35ff..760816c117 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-tablet.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--weather-tablet.yaml @@ -119,4 +119,4 @@ - listitem: - text: Fri - img "Fri" - - text: /\d+° \d+°/ \ No newline at end of file + - text: /\d+° \d+°/ diff --git a/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout--mobile.yaml b/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout--mobile.yaml index e1df77b310..c3fc96eb42 100644 --- a/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout--mobile.yaml +++ b/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout--mobile.yaml @@ -4,7 +4,7 @@ - button "Launchpad" - heading "Smart Buildings" [level=1] - button "API" - - button "Jane Smith" + - button "Jane Smith": JS - navigation: - button "expand" - main: @@ -27,6 +27,7 @@ - text: Today's motivation level (without global filter) - menubar: - menuitem "Expand" + - application - text: Lorem ipsum - paragraph: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc at augue ornare, suscipit orci a, dapibus enim. Duis nibh diam, consequat vitae dolor eu, consectetur hendrerit nulla. Nam consectetur consequat suscipit. Nam consectetur pulvinar risus, nec auctor lorem. Sed eget magna quis turpis finibus rhoncus. - text: Door access events this week @@ -36,7 +37,10 @@ - text: Reasons to remember the name - menubar: - menuitem "Expand" - - text: Daily requirements at work Contributions + - application + - text: Daily requirements at work + - application + - text: Contributions - button "Toggle" - text: /\d+ bits Valuable bits commited to main\./ - link "Do something": diff --git a/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout.yaml b/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout.yaml index 0310e9ad46..962e61955a 100644 --- a/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout.yaml +++ b/playwright/snapshots/si-dashboard.spec.ts-snapshots/si-dashboard--si-dashboard-layout.yaml @@ -6,7 +6,7 @@ - /url: "#/" - heading "Smart Buildings" [level=1] - button "API" - - button "Jane Smith" + - button "Jane Smith": JS - navigation: - button "collapse" [expanded] - button "Home" @@ -38,6 +38,7 @@ - text: Today's motivation level (without global filter) - menubar: - menuitem "Expand" + - application - text: Lorem ipsum - paragraph: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc at augue ornare, suscipit orci a, dapibus enim. Duis nibh diam, consequat vitae dolor eu, consectetur hendrerit nulla. Nam consectetur consequat suscipit. Nam consectetur pulvinar risus, nec auctor lorem. Sed eget magna quis turpis finibus rhoncus. - text: Door access events this week @@ -47,7 +48,10 @@ - text: Reasons to remember the name - menubar: - menuitem "Expand" - - text: Daily requirements at work Contributions + - application + - text: Daily requirements at work + - application + - text: Contributions - button "Toggle" - text: /\d+ bits Valuable bits commited to main\./ - link "Do something": diff --git a/projects/charts-ng/common/si-chart-base.component.html b/projects/charts-ng/common/si-chart-base.component.html index 28a5be9c32..d2091a90d3 100644 --- a/projects/charts-ng/common/si-chart-base.component.html +++ b/projects/charts-ng/common/si-chart-base.component.html @@ -34,7 +34,16 @@ /> } } -
+
@if (externalZoomSlider()) { diff --git a/projects/charts-ng/common/si-chart-base.component.ts b/projects/charts-ng/common/si-chart-base.component.ts index 0ad056c7c0..af5c2c979e 100644 --- a/projects/charts-ng/common/si-chart-base.component.ts +++ b/projects/charts-ng/common/si-chart-base.component.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { LiveAnnouncer } from '@angular/cdk/a11y'; import { AfterViewInit, ChangeDetectionStrategy, @@ -58,6 +59,12 @@ import { themeSupport } from './theme-support'; styleUrl: './si-chart-base.component.scss', changeDetection: ChangeDetectionStrategy.Eager, host: { + '[attr.tabindex]': '!showCustomLegend() ? 0 : null', + '[attr.role]': '!showCustomLegend() ? "application" : null', + '[attr.aria-label]': '!showCustomLegend() ? title() : null', + '(keydown)': 'onChartKeydown($event)', + '(focus)': 'onChartFocus()', + '(blur)': 'onChartBlur()', '(window:theme-switch)': 'themeSwitch()' } }) @@ -76,7 +83,6 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O protected readonly siCustomLegend = viewChildren('siCustomLegend', { read: SiCustomLegendComponent }); - /** * See [ECharts 5.x Documentation]{@link https://echarts.apache.org/en/option.html} * for all available options. @@ -88,6 +94,10 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O readonly title = input(); /** The subtitle of the chart. */ readonly subTitle = input(); + /** + * Aria label for the chart. + */ + readonly ariaLabel = input(); /** * Show Echarts legend * @@ -264,6 +274,10 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O private extZoomSliderChart!: echarts.ECharts; private echartElement!: HTMLElement; private eChartExtSliderElement!: HTMLElement; + /** Tracks which data point is currently highlighted via keyboard navigation. */ + private keyNavDataIndex = 0; + /** True when focus is arriving from a mouse click — suppresses the keyboard-nav showTip on focus. */ + private focusFromMouse = false; protected readonly inProgress = signal(false); protected readonly backgroundColor = signal(''); protected readonly textColor = signal(''); @@ -285,6 +299,8 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O private measureCanvas?: CanvasRenderingContext2D; private readonly cdRef = inject(ChangeDetectorRef); private readonly ngZone = inject(NgZone); + private readonly hostEl = inject(ElementRef); + private readonly liveAnnouncer = inject(LiveAnnouncer); protected curWidth = 0; protected curHeight = 0; @@ -539,6 +555,7 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O const opts = { renderer: this.renderer() }; this.chart = echarts.init(chartContainerEl, this.activeTheme, opts); this.echartElement = chartContainerEl as HTMLElement; + this.getEChartInner()?.addEventListener('mousedown', this.echartMouseDown); this.chart.setOption(this.actualOptions); setTimeout(() => this.checkGridSizeChange()); @@ -728,6 +745,249 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O protected applyOptions(): void {} + /** Shows the tooltip at the first data point when the chart receives focus. */ + protected onChartFocus(): void { + if (!this.chart) { + return; + } + // When focus arrives from a mouse click, ECharts handles the tooltip itself — skip keyboard-nav showTip. + if (this.focusFromMouse) { + this.focusFromMouse = false; + this.keyNavDataIndex = 0; + return; + } + this.keyNavDataIndex = 0; + const series = this.actualOptions.series; + const firstDataSeriesIndex = Array.isArray(series) + ? series.findIndex((s: any) => Array.isArray(s?.data) && s.data.length > 0) + : -1; + if (firstDataSeriesIndex < 0) { + return; + } + this.ngZone.runOutsideAngular(() => { + this.chart.dispatchAction({ + type: 'showTip', + seriesIndex: firstDataSeriesIndex, + dataIndex: 0 + }); + }); + this.announceDataPoint(0); + } + + protected onChartBlur(): void { + if (!this.chart) { + return; + } + this.ngZone.runOutsideAngular(() => { + this.chart.dispatchAction({ type: 'hideTip' }); + // Clear the axis pointers, highlights, and hover states when the + // chart loses focus — matching the visual reset that a real mouseout triggers. + this.chart.getZr().trigger('globalout', {}); + }); + } + + protected onChartKeydown(event: KeyboardEvent): void { + if (!this.chart) { + return; + } + + if (this.handleLegendKeydown(event)) { + return; + } + + if (this.handleZoomKeydown(event)) { + return; + } + + this.handleTooltipKeydown(event); + } + + /** + * Handles keyboard navigation within the custom-legend items. + */ + private handleLegendKeydown(event: KeyboardEvent): boolean { + const legendItems = Array.from( + this.hostEl.nativeElement.querySelectorAll('.legend-item') + ) as HTMLElement[]; + const legendIdx = legendItems.indexOf(event.target as HTMLElement); + if (legendIdx === -1) { + return false; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + legendItems[legendIdx + 1]?.focus(); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + legendItems[legendIdx - 1]?.focus(); + } else if (event.key === 'Tab' && !event.shiftKey) { + // Tab from any legend item jumps directly to the chart container. + event.preventDefault(); + this.chartContainer()?.nativeElement?.focus(); + } + return true; + } + + /** + * Handles zoom-slider keyboard controls when the zoom slider is enabled. + */ + private handleZoomKeydown(event: KeyboardEvent): boolean { + if (!this.zoomSlider()) { + return false; + } + + const dz = this.getOptionNoClone()?.dataZoom?.[0]; + if (dz === undefined) { + return false; + } + + const step = 10; + let start: number = dz.start ?? 0; + let end: number = dz.end ?? 100; + const span = end - start; + let handled = true; + + if (event.key === '+' || event.key === '=') { + // Zoom in: shrink the visible window symmetrically around its centre. + event.preventDefault(); + const center = (start + end) / 2; + const newHalf = Math.max(span / 2 - step / 2, step / 2); + start = Math.max(0, center - newHalf); + end = Math.min(100, center + newHalf); + } else if (event.key === '-') { + // Zoom out: grow the visible window symmetrically around its centre. + event.preventDefault(); + const center = (start + end) / 2; + const newHalf = Math.min(span / 2 + step / 2, 50); + start = Math.max(0, center - newHalf); + end = Math.min(100, center + newHalf); + } else if (event.shiftKey && event.key === 'ArrowLeft') { + // Pan left: shift the window towards the start. + event.preventDefault(); + start = Math.max(0, start - step); + end = start + span; + if (end > 100) { + end = 100; + start = 100 - span; + } + } else if (event.shiftKey && event.key === 'ArrowRight') { + // Pan right: shift the window towards the end. + event.preventDefault(); + end = Math.min(100, end + step); + start = end - span; + if (start < 0) { + start = 0; + end = span; + } + } else if (event.key === 'Home') { + // Reset to the full data range. + event.preventDefault(); + start = 0; + end = 100; + } else { + handled = false; + } + + if (handled) { + this.ngZone.runOutsideAngular(() => { + this.chart.dispatchAction({ type: 'dataZoom', start, end }); + }); + } + return handled; + } + + /** + * Handles tooltip/data-point keyboard navigation on the chart. + * Escape clears the tooltip and resets ECharts hover state. + * ArrowLeft/Right move the highlighted data point along the first data series. + */ + private handleTooltipKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.ngZone.runOutsideAngular(() => { + this.chart.dispatchAction({ type: 'hideTip' }); + // Simulate a mouse-leave on ECharts' internal renderer (ZRender) so that + // axis pointers, highlights, and hover states are fully cleared — same as onChartBlur. + this.chart.getZr().trigger('globalout', {}); + }); + return; + } + + const series = this.actualOptions.series; + if (!Array.isArray(series) || series.length === 0) { + return; + } + + // Find the first series that has actual data to navigate. + const firstDataSeriesIndex = series.findIndex( + (s: any) => Array.isArray(s?.data) && s.data.length > 0 + ); + if (firstDataSeriesIndex < 0) { + return; + } + const dataLen = (series[firstDataSeriesIndex] as any).data.length as number; + + switch (event.key) { + case 'ArrowRight': { + event.preventDefault(); + this.keyNavDataIndex = Math.min(this.keyNavDataIndex + 1, dataLen - 1); + break; + } + case 'ArrowLeft': { + event.preventDefault(); + this.keyNavDataIndex = Math.max(this.keyNavDataIndex - 1, 0); + break; + } + default: + return; + } + + this.ngZone.runOutsideAngular(() => { + this.chart.dispatchAction({ + type: 'showTip', + seriesIndex: firstDataSeriesIndex, + dataIndex: this.keyNavDataIndex + }); + }); + this.announceDataPoint(this.keyNavDataIndex); + } + /** + * Announces the current keyboard-navigated data point to screen readers via + * Angular CDK LiveAnnouncer. + */ + private announceDataPoint(dataIndex: number): void { + const series = this.actualOptions.series; + if (!Array.isArray(series)) { + return; + } + + let xLabel: string | undefined; + const parts = series + .filter((s: any) => Array.isArray(s?.data) && s.data[dataIndex] != null) + .map((s: any) => { + const point = s.data[dataIndex]; + const value = Array.isArray(point) + ? ((xLabel ??= String(point[0])), point[1]) + : (point?.value ?? point); + return s.name ? `${s.name}: ${value}` : String(value); + }); + + if (xLabel === undefined) { + const xAxisData = Array.isArray(this.actualOptions.xAxis) + ? this.actualOptions.xAxis[0]?.data + : this.actualOptions.xAxis?.data; + xLabel = Array.isArray(xAxisData) + ? String(xAxisData[dataIndex] ?? '') || undefined + : undefined; + } + + if (parts.length > 0) { + this.liveAnnouncer.announce( + xLabel ? `${xLabel}. ${parts.join('. ')}` : parts.join('. '), + 'polite' + ); + } + } + protected applyCustomLegendPosition(): void { if (this.showLegend() && this.showCustomLegend()) { this.customLegend.forEach(cl => { @@ -997,6 +1257,7 @@ export class SiChartBaseComponent implements AfterViewInit, OnChanges, OnInit, O } private handleChartMouseDown(): void { + this.focusFromMouse = true; window.addEventListener('mouseup', this.echartMouseUp); } diff --git a/projects/charts-ng/custom-legend/si-custom-legend.component.html b/projects/charts-ng/custom-legend/si-custom-legend.component.html index 4841f7e92c..f7152b08da 100644 --- a/projects/charts-ng/custom-legend/si-custom-legend.component.html +++ b/projects/charts-ng/custom-legend/si-custom-legend.component.html @@ -18,27 +18,34 @@