Skip to content

Commit 87629e2

Browse files
authored
feat(a11y): allow user to add optional semantic meaning to goal/gauge charts (#1218)
Closes #1161
1 parent f8a7111 commit 87629e2

13 files changed

Lines changed: 185 additions & 5 deletions

File tree

Loading

packages/charts/api/charts.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,8 @@ export interface GoalSpec extends Spec {
783783
// (undocumented)
784784
bandFillColor: BandFillColorAccessor;
785785
// (undocumented)
786+
bandLabels: string[];
787+
// (undocumented)
786788
bands: number[];
787789
// (undocumented)
788790
base: number;

packages/charts/src/chart_types/goal_chart/layout/types/viewmodel_types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import { GoalSubtype } from '../../specs/constants';
2424
import { config } from '../config/config';
2525
import { Config } from './config_types';
2626

27-
interface BandViewModel {
27+
/** @internal */
28+
export interface BandViewModel {
2829
value: number;
2930
fillColor: string;
31+
text: string[];
3032
}
3133

3234
interface TickViewModel {
@@ -90,6 +92,7 @@ export const defaultGoalSpec = {
9092
labelMinor: ({}: BandFillColorAccessorInput) => 'unit',
9193
centralMajor: ({ base }: BandFillColorAccessorInput) => String(base),
9294
centralMinor: ({ target }: BandFillColorAccessorInput) => String(target),
95+
bandLabels: [],
9396
};
9497

9598
/** @internal */

packages/charts/src/chart_types/goal_chart/layout/viewmodel/viewmodel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function shapeViewModel(textMeasure: TextMeasure, spec: GoalSpec, config:
5252
labelMinor,
5353
centralMajor,
5454
centralMinor,
55+
bandLabels,
5556
} = spec;
5657

5758
const [lowestValue, highestValue] = [base, target, actual, ...bands, ...ticks].reduce(
@@ -80,6 +81,7 @@ export function shapeViewModel(textMeasure: TextMeasure, spec: GoalSpec, config:
8081
bands: bands.map((value: number, index: number) => ({
8182
value,
8283
fillColor: bandFillColor({ value, index, ...callbackArgs }),
84+
text: bandLabels,
8385
})),
8486
ticks: ticks.map((value: number, index: number) => ({
8587
value,

packages/charts/src/chart_types/goal_chart/renderer/canvas/canvas_renderers.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ export function renderCanvas2d(
8585
const vertical = subtype === GoalSubtype.VerticalBullet;
8686

8787
const domain = [lowestValue, highestValue];
88-
8988
const data = {
9089
base: { value: base },
9190
...Object.fromEntries(bands.map(({ value }, index) => [`qualitative_${index}`, { value }])),

packages/charts/src/chart_types/goal_chart/renderer/canvas/connected_component.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import React, { MouseEvent, RefObject } from 'react';
2121
import { connect } from 'react-redux';
2222
import { bindActionCreators, Dispatch } from 'redux';
2323

24-
import { ScreenReaderSummary } from '../../../../components/accessibility';
24+
import { GoalSemanticDescription, ScreenReaderSummary } from '../../../../components/accessibility';
2525
import { onChartRendered } from '../../../../state/actions/chart';
2626
import { GlobalChartState } from '../../../../state/chart_state';
2727
import {
@@ -31,15 +31,18 @@ import {
3131
} from '../../../../state/selectors/get_accessibility_config';
3232
import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized';
3333
import { Dimensions } from '../../../../utils/dimensions';
34-
import { nullShapeViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types';
34+
import { BandViewModel, nullShapeViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types';
3535
import { geometries } from '../../state/selectors/geometries';
36+
import { getFirstTickValueSelector, getGoalChartSemanticDataSelector } from '../../state/selectors/get_goal_chart_data';
3637
import { renderCanvas2d } from './canvas_renderers';
3738

3839
interface ReactiveChartStateProps {
3940
initialized: boolean;
4041
geometries: ShapeViewModel;
4142
chartContainerDimensions: Dimensions;
4243
a11ySettings: A11ySettings;
44+
bandLabels: BandViewModel[];
45+
firstValue: number;
4346
}
4447

4548
interface ReactiveChartDispatchProps {
@@ -112,11 +115,12 @@ class Component extends React.Component<Props> {
112115
chartContainerDimensions: { width, height },
113116
forwardStageRef,
114117
a11ySettings,
118+
bandLabels,
119+
firstValue,
115120
} = this.props;
116121
if (!initialized || width === 0 || height === 0) {
117122
return null;
118123
}
119-
120124
return (
121125
<figure aria-labelledby={a11ySettings.labelId} aria-describedby={a11ySettings.descriptionId}>
122126
<canvas
@@ -133,6 +137,7 @@ class Component extends React.Component<Props> {
133137
role="presentation"
134138
>
135139
<ScreenReaderSummary />
140+
<GoalSemanticDescription bandLabels={bandLabels} firstValue={firstValue} {...a11ySettings} />
136141
</canvas>
137142
</figure>
138143
);
@@ -172,6 +177,8 @@ const DEFAULT_PROPS: ReactiveChartStateProps = {
172177
top: 0,
173178
},
174179
a11ySettings: DEFAULT_A11Y_SETTINGS,
180+
bandLabels: [],
181+
firstValue: 0,
175182
};
176183

177184
const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => {
@@ -183,6 +190,8 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => {
183190
geometries: geometries(state),
184191
chartContainerDimensions: state.parentDimensions,
185192
a11ySettings: getA11ySettingsSelector(state),
193+
bandLabels: getGoalChartSemanticDataSelector(state),
194+
firstValue: getFirstTickValueSelector(state),
186195
};
187196
};
188197

packages/charts/src/chart_types/goal_chart/specs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface GoalSpec extends Spec {
6767
centralMajor: string | BandFillColorAccessor;
6868
centralMinor: string | BandFillColorAccessor;
6969
config: RecursivePartial<Config>;
70+
bandLabels: string[];
7071
}
7172

7273
type SpecRequiredProps = Pick<GoalSpec, 'id' | 'actual'>;
@@ -83,6 +84,7 @@ export const Goal: React.FunctionComponent<SpecRequiredProps & SpecOptionalProps
8384
| 'target'
8485
| 'actual'
8586
| 'bands'
87+
| 'bandLabels'
8688
| 'ticks'
8789
| 'bandFillColor'
8890
| 'tickValueFormatter'

packages/charts/src/chart_types/goal_chart/state/selectors/get_goal_chart_data.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,13 @@ export const getGoalChartDataSelector = createCustomCachedSelector(
5252
export const getGoalChartLabelsSelector = createCustomCachedSelector([geometries], (geoms) => {
5353
return { majorLabel: geoms.bulletViewModel.labelMajor, minorLabel: geoms.bulletViewModel.labelMinor };
5454
});
55+
56+
/** @internal */
57+
export const getGoalChartSemanticDataSelector = createCustomCachedSelector([geometries], (geoms) => {
58+
return geoms.bulletViewModel.bands ?? [];
59+
});
60+
61+
/** @internal */
62+
export const getFirstTickValueSelector = createCustomCachedSelector([geometries], (geoms) => {
63+
return geoms.bulletViewModel.lowestValue;
64+
});

packages/charts/src/components/accessibility/accessibility.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,38 @@ describe('Accessibility', () => {
154154
/>
155155
</Chart>,
156156
);
157+
158+
const bandLabelsAscending = ['freezing', 'chilly', 'brisk'];
159+
const bandsAscending = [200, 250, 300];
160+
161+
const ascendingBandLabelsGoalChart = mount(
162+
<Chart className="story-chart">
163+
<Goal
164+
id="spec_1"
165+
subtype={GoalSubtype.Goal}
166+
base={0}
167+
target={260}
168+
actual={170}
169+
bands={bandsAscending}
170+
ticks={[0, 50, 100, 150, 200, 250, 300]}
171+
labelMajor="Revenue 2020 YTD "
172+
labelMinor="(thousand USD) "
173+
centralMajor="170"
174+
centralMinor=""
175+
config={{ angleStart: Math.PI, angleEnd: 0 }}
176+
bandLabels={bandLabelsAscending}
177+
/>
178+
</Chart>,
179+
);
157180
it('should test defaults for goal charts', () => {
158181
expect(goalChartWrapper.find('.echScreenReaderOnly').first().text()).toBe(
159182
'Revenue 2020 YTD (thousand USD) Chart type:goal chartMinimum:0Maximum:300Target:$260Value:170',
160183
);
161184
});
185+
it('should correctly render ascending semantic values', () => {
186+
expect(ascendingBandLabelsGoalChart.find('.echGoalDescription').first().text()).toBe(
187+
'0 - 200freezing200 - 250chilly250 - 300brisk',
188+
);
189+
});
162190
});
163191
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import React from 'react';
21+
22+
import { BandViewModel } from '../../chart_types/goal_chart/layout/types/viewmodel_types';
23+
import { A11ySettings } from '../../state/selectors/get_accessibility_config';
24+
25+
interface GoalSemanticDescriptionProps {
26+
bandLabels: BandViewModel[];
27+
firstValue: number;
28+
}
29+
30+
/** @internal */
31+
export const GoalSemanticDescription = ({
32+
bandLabels,
33+
labelId,
34+
firstValue,
35+
}: A11ySettings & GoalSemanticDescriptionProps) => {
36+
return bandLabels.length > 1 ? (
37+
<dl className="echScreenReaderOnly echGoalDescription" key={`goalChart--${labelId}`}>
38+
{bandLabels.map(({ value, text }, index) => {
39+
const prevValue = bandLabels[index - 1];
40+
return prevValue !== undefined ? (
41+
<>
42+
<dt key={`dt--${index}`}>{`${prevValue.value} - ${value}`}</dt>
43+
<dd key={`dd--${index}`}>{`${text[index]}`}</dd>
44+
</>
45+
) : (
46+
<>
47+
<dt key={`dt--${index}`}>{`${firstValue} - ${value}`}</dt>
48+
<dd key={`dd--${index}`}>{`${text[index]}`}</dd>
49+
</>
50+
);
51+
})}
52+
</dl>
53+
) : null;
54+
};

0 commit comments

Comments
 (0)