Skip to content

Commit

Permalink
feat(a11y): add data table for screen readers (sunburst, treemap, ici…
Browse files Browse the repository at this point in the history
…cle, flame) (#1155)

Fixes #1154
  • Loading branch information
rshen91 committed Jun 10, 2021
1 parent 768ebfc commit 87fd75f
Show file tree
Hide file tree
Showing 20 changed files with 639 additions and 150 deletions.
3 changes: 2 additions & 1 deletion packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ export const DEFAULT_TOOLTIP_SNAP = true;
export const DEFAULT_TOOLTIP_TYPE: "vertical";

// @public (undocumented)
export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'animateData' | 'debug' | 'tooltip' | 'theme' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents' | 'showLegend' | 'showLegendExtra' | 'legendPosition' | 'legendMaxDepth' | 'ariaUseDefaultSummary' | 'ariaLabelHeadingLevel';
export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'animateData' | 'debug' | 'tooltip' | 'theme' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents' | 'showLegend' | 'showLegendExtra' | 'legendPosition' | 'legendMaxDepth' | 'ariaUseDefaultSummary' | 'ariaLabelHeadingLevel' | 'ariaTableCaption';

// @public (undocumented)
export const DEPTH_KEY = "depth";
Expand Down Expand Up @@ -1762,6 +1762,7 @@ export interface SettingsSpec extends Spec, LegendSpec {
ariaLabel?: string;
ariaLabelHeadingLevel: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
ariaLabelledBy?: string;
ariaTableCaption?: string;
ariaUseDefaultSummary: boolean;
baseTheme?: Theme;
brushAxis?: BrushAxis;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function ratioValueGetter(node: ShapeTreeNode): number {

/** @public */
export const VALUE_GETTERS = Object.freeze({ percent: percentValueGetter, ratio: ratioValueGetter } as const);

/** @public */
export type ValueGetterName = keyof typeof VALUE_GETTERS;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,7 @@ function flatSlicesNames(
return [];
}

for (let i = 0; i < tree.length; i++) {
const branch = tree[i];
const arrayNode = branch[1];
const key = branch[0];

for (const [key, arrayNode] of tree) {
// format the key with the layer formatter
const layer = layers[depth - 1];
const formatter = layer?.nodeLabel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ describe('Retain hierarchy even with arbitrary names', () => {
MockStore.addSpecs(
[
MockGlobalSpec.settings({ showLegend: true }),
MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'A', cat2: 'A', val: 1 }] }),
MockSeriesSpec.sunburst({
...specJSON,
data: [{ cat1: 'A', cat2: 'A', val: 1, percentage: '100%', valueText: 1 }],
}),
],
store,
);
Expand All @@ -94,7 +97,10 @@ describe('Retain hierarchy even with arbitrary names', () => {
MockStore.addSpecs(
[
MockGlobalSpec.settings({ showLegend: true }),
MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'C', cat2: 'B', val: 1 }] }),
MockSeriesSpec.sunburst({
...specJSON,
data: [{ cat1: 'C', cat2: 'B', val: 1, parentName: 'A', percentage: '100%', valueText: '1' }],
}),
],
store,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';

import { ScreenReaderSummary } from '../../../../components/accessibility';
import { ScreenReaderPartitionTable } from '../../../../components/accessibility/partitions_data_table';
import { clearCanvas } from '../../../../renderers/canvas';
import { SettingsSpec } from '../../../../specs/settings';
import { onChartRendered } from '../../../../state/actions/chart';
import { ChartId, GlobalChartState } from '../../../../state/chart_state';
import {
Expand All @@ -33,6 +35,7 @@ import {
import { getChartContainerDimensionsSelector } from '../../../../state/selectors/get_chart_container_dimensions';
import { getChartIdSelector } from '../../../../state/selectors/get_chart_id';
import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized';
import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs';
import { Dimensions } from '../../../../utils/dimensions';
import { MODEL_KEY } from '../../layout/config';
import {
Expand Down Expand Up @@ -66,6 +69,7 @@ interface ReactiveChartStateProps {
chartContainerDimensions: Dimensions;
chartId: ChartId;
a11ySettings: A11ySettings;
debug: SettingsSpec['debug'];
}

interface ReactiveChartDispatchProps {
Expand Down Expand Up @@ -149,6 +153,7 @@ class PartitionComponent extends React.Component<PartitionProps> {
initialized,
chartContainerDimensions: { width, height },
a11ySettings,
debug,
} = this.props;
if (!initialized || width === 0 || height === 0) {
return null;
Expand All @@ -169,7 +174,9 @@ class PartitionComponent extends React.Component<PartitionProps> {
role="presentation"
>
<ScreenReaderSummary />
{!debug && <ScreenReaderPartitionTable />}
</canvas>
{debug && <ScreenReaderPartitionTable />}
</figure>
);
}
Expand Down Expand Up @@ -219,6 +226,7 @@ const DEFAULT_PROPS: ReactiveChartStateProps = {
top: 0,
},
a11ySettings: DEFAULT_A11Y_SETTINGS,
debug: false,
};

const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => {
Expand All @@ -234,6 +242,7 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => {
geometriesFoci: partitionDrilldownFocus(state),
chartId: getChartIdSelector(state),
a11ySettings: getA11ySettingsSelector(state),
debug: getSettingsSpecSelector(state).debug,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,3 @@ export const partitionDrilldownFocus = createCachedSelector(
return { currentFocusX0, currentFocusX1, prevFocusX0, prevFocusX1, smAccessorValue, index, innerIndex };
}),
)((state) => state.chartId);

/** @internal */
export const partitionGeometries = createCachedSelector(
[partitionMultiGeometries],
(multiGeometries: ShapeViewModel[]) => {
return [
multiGeometries.length > 0 // singleton!
? multiGeometries[0]
: nullShapeViewModel(),
];
},
)(getChartIdSelector);
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ import { getTrees } from './tree';
export const getLegendItemsLabels = createCachedSelector(
[getPartitionSpecs, getSettingsSpecSelector, getTrees],
(specs, { legendMaxDepth, showLegend }, trees): LegendItemLabel[] =>
specs.flatMap((spec) => (showLegend ? getLegendLabels(spec.layers, trees[0].tree, legendMaxDepth) : [])), // singleton! wrt inner small multiples
specs.flatMap((spec) =>
showLegend ? trees.flatMap(({ tree }) => getLegendLabels(spec.layers, tree, legendMaxDepth)) : [],
),
)(getChartIdSelector);
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { Store } from 'redux';

import { MockSeriesSpec } from '../../../../mocks/specs/specs';
import { MockStore } from '../../../../mocks/store';
import { GlobalChartState } from '../../../../state/chart_state';
import { PrimitiveValue } from '../../layout/utils/group_by_rollup';
import { getScreenReaderDataSelector } from './get_screen_reader_data';

describe('Get screen reader data', () => {
type TestDatum = [string, string, string, number];
const spec1 = MockSeriesSpec.sunburst({
data: [
['aaa', 'aa', '1', 1],
['aaa', 'aa', '3', 1],
['aaa', 'bb', '4', 1],
],
valueAccessor: (d: TestDatum) => d[3],
layers: [
{
groupByRollup: (datum: TestDatum) => datum[0],
nodeLabel: (d: PrimitiveValue) => String(d),
},
{
groupByRollup: (datum: TestDatum) => datum[1],
nodeLabel: (d: PrimitiveValue) => String(d),
},
{
groupByRollup: (datum: TestDatum) => datum[2],
nodeLabel: (d: PrimitiveValue) => String(d),
},
],
});

const specNoSlice = MockSeriesSpec.sunburst({
data: [],
valueAccessor: (d: TestDatum) => d[3],
layers: [
{
groupByRollup: (datum: TestDatum) => datum[0],
nodeLabel: (d: PrimitiveValue) => String(d),
},
{
groupByRollup: (datum: TestDatum) => datum[1],
nodeLabel: (d: PrimitiveValue) => String(d),
},
{
groupByRollup: (datum: TestDatum) => datum[2],
nodeLabel: (d: PrimitiveValue) => String(d),
},
],
});
let store: Store<GlobalChartState>;

beforeEach(() => {
store = MockStore.default();
});

it('should test defaults', () => {
MockStore.addSpecs([spec1], store);
const expected = getScreenReaderDataSelector(store.getState());
expect(expected).toEqual({
data: [
{ depth: 1, label: 'aaa', panelTitle: '', parentName: 'none', percentage: '100%', value: 3, valueText: '3' },
{ depth: 2, label: 'aa', panelTitle: '', parentName: 'aaa', percentage: '67%', value: 2, valueText: '2' },
{ depth: 3, label: '1', panelTitle: '', parentName: 'aa', percentage: '33%', value: 1, valueText: '1' },
{ depth: 3, label: '3', panelTitle: '', parentName: 'aa', percentage: '33%', value: 1, valueText: '1' },
{ depth: 2, label: 'bb', panelTitle: '', parentName: 'aaa', percentage: '33%', value: 1, valueText: '1' },
{ depth: 3, label: '4', panelTitle: '', parentName: 'bb', percentage: '33%', value: 1, valueText: '1' },
],
hasMultipleLayers: true,
isSmallMultiple: false,
});
});
it('should compute screen reader data for no slices in pie', () => {
MockStore.addSpecs([specNoSlice], store);
const expected = getScreenReaderDataSelector(store.getState());
expect(expected).toEqual({
data: [],
hasMultipleLayers: true,
isSmallMultiple: false,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import createCachedSelector from 're-reselect';

import { getChartIdSelector } from '../../../../state/selectors/get_chart_id';
import { ShapeViewModel } from '../../layout/types/viewmodel_types';
import { STATISTICS_KEY } from '../../layout/utils/group_by_rollup';
import { PartitionSpec } from '../../specs';
import { partitionMultiGeometries } from './geometries';
import { getPartitionSpecs } from './get_partition_specs';

/** @internal */
export interface PartitionSectionData {
panelTitle?: string;
label: string;
parentName: string | undefined;
depth: number;
percentage: string;
value: number;
valueText: string;
}

/** @internal */
export interface PartitionData {
hasMultipleLayers: boolean;
isSmallMultiple: boolean;
data: PartitionSectionData[];
}

/**
* @internal
*/
const getScreenReaderDataForPartitions = (
[{ valueFormatter }]: PartitionSpec[],
shapeViewModels: ShapeViewModel[],
): PartitionSectionData[] => {
return shapeViewModels.flatMap(({ quadViewModel, layers, panelTitle }) =>
quadViewModel.map(({ depth, value, dataName, parent, path }) => {
const label = layers[depth - 1]?.nodeLabel?.(dataName) ?? dataName;
const parentValue = path.length > 1 ? path[path.length - 2].value : undefined;
const parentName =
depth > 1 && parentValue ? layers[depth - 2]?.nodeLabel?.(parentValue) ?? path[path.length - 1].value : 'none';

return {
panelTitle,
depth,
label,
parentName,
percentage: `${Math.round((value / parent[STATISTICS_KEY].globalAggregate) * 100)}%`,
value,
valueText: valueFormatter ? valueFormatter(value) : `${value}`,
};
}),
);
};

/** @internal */
export const getScreenReaderDataSelector = createCachedSelector(
[getPartitionSpecs, partitionMultiGeometries],
(specs, shapeViewModel): PartitionData => {
if (specs.length === 0) {
return {
hasMultipleLayers: false,
isSmallMultiple: false,
data: [],
};
}
return {
hasMultipleLayers: specs[0].layers.length > 1,
isSmallMultiple: shapeViewModel.length > 1,
data: getScreenReaderDataForPartitions(specs, shapeViewModel),
};
},
)(getChartIdSelector);

0 comments on commit 87fd75f

Please sign in to comment.