From 87fd75facf96845629307ef3d2f5d67a4f9029d1 Mon Sep 17 00:00:00 2001 From: Rachel Shen Date: Thu, 10 Jun 2021 08:46:49 -0600 Subject: [PATCH] feat(a11y): add data table for screen readers (sunburst, treemap, icicle, flame) (#1155) Fixes #1154 --- packages/charts/api/charts.api.md | 3 +- .../partition_chart/layout/config.ts | 1 + .../layout/utils/legend_labels.ts | 6 +- .../partition_chart/partition.test.tsx | 10 +- .../renderer/canvas/partition.tsx | 9 ++ .../state/selectors/geometries.ts | 12 -- .../selectors/get_legend_items_labels.ts | 4 +- .../selectors/get_screen_reader_data.test.ts | 102 ++++++++++++ .../state/selectors/get_screen_reader_data.ts | 91 +++++++++++ .../state/selectors/picked_shapes.test.ts | 15 +- .../xy_chart/renderer/canvas/xy_chart.tsx | 35 ++-- .../xy_chart/renderer/dom/_screen_reader.scss | 22 +++ .../__snapshots__/chart.test.tsx.snap | 39 +++-- .../accessibility/accessibility.test.tsx | 115 +++++++++++-- .../accessibility/partitions_data_table.tsx | 152 ++++++++++++++++++ packages/charts/src/specs/settings.tsx | 7 +- .../selectors/get_accessibility_config.ts | 12 +- stories/small_multiples/7_sunbursts.tsx | 1 + stories/sunburst/1_simple.tsx | 44 ++--- stories/sunburst/8_sunburst_two_layers.tsx | 109 +++++++------ 20 files changed, 639 insertions(+), 150 deletions(-) create mode 100644 packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.test.ts create mode 100644 packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.ts create mode 100644 packages/charts/src/components/accessibility/partitions_data_table.tsx diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 2d6a0e5c96..b96d4941ac 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -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"; @@ -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; diff --git a/packages/charts/src/chart_types/partition_chart/layout/config.ts b/packages/charts/src/chart_types/partition_chart/layout/config.ts index 04e1ce8b58..48ae5a2de8 100644 --- a/packages/charts/src/chart_types/partition_chart/layout/config.ts +++ b/packages/charts/src/chart_types/partition_chart/layout/config.ts @@ -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; diff --git a/packages/charts/src/chart_types/partition_chart/layout/utils/legend_labels.ts b/packages/charts/src/chart_types/partition_chart/layout/utils/legend_labels.ts index b7d85ffed9..6519114c63 100644 --- a/packages/charts/src/chart_types/partition_chart/layout/utils/legend_labels.ts +++ b/packages/charts/src/chart_types/partition_chart/layout/utils/legend_labels.ts @@ -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; diff --git a/packages/charts/src/chart_types/partition_chart/partition.test.tsx b/packages/charts/src/chart_types/partition_chart/partition.test.tsx index f67e3dbec0..c102890dad 100644 --- a/packages/charts/src/chart_types/partition_chart/partition.test.tsx +++ b/packages/charts/src/chart_types/partition_chart/partition.test.tsx @@ -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, ); @@ -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, ); diff --git a/packages/charts/src/chart_types/partition_chart/renderer/canvas/partition.tsx b/packages/charts/src/chart_types/partition_chart/renderer/canvas/partition.tsx index 0477f1cc72..fe0f0e1d4c 100644 --- a/packages/charts/src/chart_types/partition_chart/renderer/canvas/partition.tsx +++ b/packages/charts/src/chart_types/partition_chart/renderer/canvas/partition.tsx @@ -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 { @@ -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 { @@ -66,6 +69,7 @@ interface ReactiveChartStateProps { chartContainerDimensions: Dimensions; chartId: ChartId; a11ySettings: A11ySettings; + debug: SettingsSpec['debug']; } interface ReactiveChartDispatchProps { @@ -149,6 +153,7 @@ class PartitionComponent extends React.Component { initialized, chartContainerDimensions: { width, height }, a11ySettings, + debug, } = this.props; if (!initialized || width === 0 || height === 0) { return null; @@ -169,7 +174,9 @@ class PartitionComponent extends React.Component { role="presentation" > + {!debug && } + {debug && } ); } @@ -219,6 +226,7 @@ const DEFAULT_PROPS: ReactiveChartStateProps = { top: 0, }, a11ySettings: DEFAULT_A11Y_SETTINGS, + debug: false, }; const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => { @@ -234,6 +242,7 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => { geometriesFoci: partitionDrilldownFocus(state), chartId: getChartIdSelector(state), a11ySettings: getA11ySettingsSelector(state), + debug: getSettingsSpecSelector(state).debug, }; }; diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/geometries.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/geometries.ts index 0a81254ec2..f20cfb6856 100644 --- a/packages/charts/src/chart_types/partition_chart/state/selectors/geometries.ts +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/geometries.ts @@ -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); diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts index 9074e56305..fc43934eb5 100644 --- a/packages/charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts @@ -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); diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.test.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.test.ts new file mode 100644 index 0000000000..aa15aac45c --- /dev/null +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.test.ts @@ -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; + + 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, + }); + }); +}); diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.ts new file mode 100644 index 0000000000..76b396d9b5 --- /dev/null +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/get_screen_reader_data.ts @@ -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); diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts index e21ee8c661..fbbf74f4c5 100644 --- a/packages/charts/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts @@ -37,7 +37,7 @@ import { chartStoreReducer, GlobalChartState } from '../../../../state/chart_sta import { Datum } from '../../../../utils/common'; import { HIERARCHY_ROOT_KEY } from '../../layout/utils/group_by_rollup'; import { PartitionSpec } from '../../specs'; -import { partitionGeometries } from './geometries'; +import { partitionMultiGeometries } from './geometries'; import { createOnElementClickCaller } from './on_element_click_caller'; describe('Picked shapes selector', () => { @@ -45,12 +45,14 @@ describe('Picked shapes selector', () => { const storeReducer = chartStoreReducer('chartId'); return createStore(storeReducer); } + function addSeries(store: Store, spec: PartitionSpec, settings?: Partial) { store.dispatch(upsertSpec(MockGlobalSpec.settings(settings))); store.dispatch(upsertSpec(spec)); store.dispatch(specParsed()); store.dispatch(updateParentDimensions({ width: 300, height: 300, top: 0, left: 0 })); } + function addSmallMultiplesSeries( store: Store, groupBy: Partial, @@ -65,6 +67,7 @@ describe('Picked shapes selector', () => { store.dispatch(specParsed()); store.dispatch(updateParentDimensions({ width: 300, height: 300, top: 0, left: 0 })); } + let store: Store; let treemapSpec: PartitionSpec; let sunburstSpec: PartitionSpec; @@ -92,11 +95,11 @@ describe('Picked shapes selector', () => { }); test('check initial geoms', () => { addSeries(store, treemapSpec); - const treemapGeometries = partitionGeometries(store.getState())[0]; + const treemapGeometries = partitionMultiGeometries(store.getState())[0]; expect(treemapGeometries.quadViewModel).toHaveLength(6); addSeries(store, sunburstSpec); - const sunburstGeometries = partitionGeometries(store.getState())[0]; + const sunburstGeometries = partitionMultiGeometries(store.getState())[0]; expect(sunburstGeometries.quadViewModel).toHaveLength(6); }); test('treemap check picked geometries', () => { @@ -107,7 +110,7 @@ describe('Picked shapes selector', () => { addSeries(store, treemapSpec, { onElementClick: onClickListener, }); - const geometries = partitionGeometries(store.getState())[0]; + const geometries = partitionMultiGeometries(store.getState())[0]; expect(geometries.quadViewModel).toHaveLength(6); const onElementClickCaller = createOnElementClickCaller(); @@ -185,7 +188,7 @@ describe('Picked shapes selector', () => { onElementClick: onClickListener, }, ); - const geometries = partitionGeometries(store.getState())[0]; + const geometries = partitionMultiGeometries(store.getState())[0]; expect(geometries.quadViewModel).toHaveLength(2); const onElementClickCaller = createOnElementClickCaller(); @@ -228,7 +231,7 @@ describe('Picked shapes selector', () => { addSeries(store, sunburstSpec, { onElementClick: onClickListener, }); - const geometries = partitionGeometries(store.getState())[0]; + const geometries = partitionMultiGeometries(store.getState())[0]; expect(geometries.quadViewModel).toHaveLength(6); const onElementClickCaller = createOnElementClickCaller(); diff --git a/packages/charts/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx b/packages/charts/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx index 5338c08a66..6c83e3dbbd 100644 --- a/packages/charts/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx +++ b/packages/charts/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx @@ -160,6 +160,7 @@ class XYChartComponent extends React.Component { isChartEmpty, chartContainerDimensions: { width, height }, a11ySettings, + debug, } = this.props; if (!initialized || isChartEmpty) { @@ -168,22 +169,24 @@ class XYChartComponent extends React.Component { } return ( -
- - - -
+ <> +
+ + {!debug && } +
+ {debug && } + ); } } diff --git a/packages/charts/src/chart_types/xy_chart/renderer/dom/_screen_reader.scss b/packages/charts/src/chart_types/xy_chart/renderer/dom/_screen_reader.scss index 0bd8bafaf9..5f622b19bd 100644 --- a/packages/charts/src/chart_types/xy_chart/renderer/dom/_screen_reader.scss +++ b/packages/charts/src/chart_types/xy_chart/renderer/dom/_screen_reader.scss @@ -6,3 +6,25 @@ height: 1px; overflow: hidden; } + +.echScreenReaderOnlyDebug { + left: 0 !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + overflow: auto !important; + background: rgba(255, 255, 255, 0.8); + table, + td, + th { + border: 1px solid black; + font-size: 12px; + } +} + +.echScreenReaderTable { + overflow-x: auto; + text-align: left; +} diff --git a/packages/charts/src/components/__snapshots__/chart.test.tsx.snap b/packages/charts/src/components/__snapshots__/chart.test.tsx.snap index de3a040a84..a8c8841cec 100644 --- a/packages/charts/src/components/__snapshots__/chart.test.tsx.snap +++ b/packages/charts/src/components/__snapshots__/chart.test.tsx.snap @@ -71,27 +71,26 @@ exports[`Chart should render the legend name test 1`] = `
- - - -
- - - -
-
- Chart type: -
-
- bar chart -
-
-
-
-
-
-
+
+ + +
+ + + +
+
+ Chart type: +
+
+ bar chart +
+
+
+
+
+
diff --git a/packages/charts/src/components/accessibility/accessibility.test.tsx b/packages/charts/src/components/accessibility/accessibility.test.tsx index 09fae35f65..668cadbc10 100644 --- a/packages/charts/src/components/accessibility/accessibility.test.tsx +++ b/packages/charts/src/components/accessibility/accessibility.test.tsx @@ -20,27 +20,116 @@ import { mount } from 'enzyme'; import React from 'react'; -import { BarSeries, LineSeries, Settings } from '../../specs'; +import { config } from '../../chart_types/partition_chart/layout/config'; +import { PartitionLayout } from '../../chart_types/partition_chart/layout/types/config_types'; +import { arrayToLookup } from '../../common/color_calcs'; +import { mocks } from '../../mocks/hierarchical'; +import { productDimension } from '../../mocks/hierarchical/dimension_codes'; +import { BarSeries, LineSeries, Partition, Settings } from '../../specs'; +import { Datum } from '../../utils/common'; import { Chart } from '../chart'; describe('Accessibility', () => { - it('should include the series types if one type of series', () => { - const wrapper = mount( + describe('Screen reader summary xy charts', () => { + it('should include the series types if one type of series', () => { + const wrapper = mount( + + + + , + ); + expect(wrapper.find('dd').first().text()).toBe('bar chart'); + }); + it('should include the series types if multiple types of series', () => { + const wrapper = mount( + + + + + , + ); + expect(wrapper.find('dd').first().text()).toBe('Mixed chart: bar and line chart'); + }); + }); + + describe('Partition charts accessibility', () => { + const productLookup = arrayToLookup((d: any) => d.sitc1, productDimension); + type TestDatum = { cat1: string; cat2: string; val: number }; + + const sunburstWrapper = mount( - - + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + }, + ]} + /> , ); - expect(wrapper.find('dd').first().text()).toBe('bar chart'); - }); - it('should include the series types if multiple types of series', () => { - const wrapper = mount( + + const treemapWrapper = mount( - - - + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + }, + ]} + config={{ + partitionLayout: PartitionLayout.treemap, + }} + /> , ); - expect(wrapper.find('dd').first().text()).toBe('Mixed chart: bar and line chart'); + + const sunburstLayerWrapper = mount( + + + d.val} + layers={[ + { + groupByRollup: (d: TestDatum) => d.cat1, + }, + { + groupByRollup: (d: TestDatum) => d.cat2, + }, + ]} + /> + , + ); + + it('should include the series type if partition chart', () => { + expect(sunburstWrapper.find('dd').first().text()).toBe('sunburst chart'); + }); + it('should include series type if treemap type', () => { + expect(treemapWrapper.find('dd').first().text()).toBe('treemap chart'); + }); + it('should test defaults for screen reader data table', () => { + expect(sunburstWrapper.find('tr').first().text()).toBe('LabelValuePercentage'); + }); + it('should include additional columns if a multilayer pie chart', () => { + expect(sunburstLayerWrapper.find('tr').first().text()).toBe('DepthLabelParentValuePercentage'); + }); }); }); diff --git a/packages/charts/src/components/accessibility/partitions_data_table.tsx b/packages/charts/src/components/accessibility/partitions_data_table.tsx new file mode 100644 index 0000000000..e313cc1c4d --- /dev/null +++ b/packages/charts/src/components/accessibility/partitions_data_table.tsx @@ -0,0 +1,152 @@ +/* + * 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 React, { createRef, memo, useState } from 'react'; +import { connect } from 'react-redux'; + +import { + getScreenReaderDataSelector, + PartitionData, +} from '../../chart_types/partition_chart/state/selectors/get_screen_reader_data'; +import { SettingsSpec } from '../../specs/settings'; +import { GlobalChartState } from '../../state/chart_state'; +import { + A11ySettings, + DEFAULT_A11Y_SETTINGS, + getA11ySettingsSelector, +} from '../../state/selectors/get_accessibility_config'; +import { getInternalIsInitializedSelector, InitStatus } from '../../state/selectors/get_internal_is_intialized'; +import { getSettingsSpecSelector } from '../../state/selectors/get_settings_specs'; +import { isNil } from '../../utils/common'; + +interface ScreenReaderPartitionTableProps { + a11ySettings: A11ySettings; + partitionData: PartitionData; + debug: SettingsSpec['debug']; +} + +// this currently limit the number of pages shown to the user +const TABLE_PAGINATION = 20; + +const ScreenReaderPartitionTableComponent = ({ + a11ySettings, + partitionData, + debug, +}: ScreenReaderPartitionTableProps) => { + const [count, setCount] = useState(1); + const tableRowRef = createRef(); + const { tableCaption } = a11ySettings; + + const rowLimit = TABLE_PAGINATION * count; + const handleMoreData = () => { + setCount(count + 1); + // generate the next group of data + if (tableRowRef.current) { + tableRowRef.current.focus(); + } + }; + + const { isSmallMultiple, data, hasMultipleLayers } = partitionData; + const tableLength = data.length; + const showMoreRows = rowLimit < tableLength; + let countOfCol: number = 3; + const totalColumns: number = + hasMultipleLayers && isSmallMultiple + ? (countOfCol += 3) + : hasMultipleLayers || isSmallMultiple + ? (countOfCol += 2) + : countOfCol; + + return ( +
+ + + + + {isSmallMultiple && } + {hasMultipleLayers && } + + {hasMultipleLayers && } + + + + + + + {partitionData.data + .slice(0, rowLimit) + .map(({ panelTitle, depth, label, parentName, valueText, percentage }, index) => { + return ( + + {isSmallMultiple && } + {hasMultipleLayers && } + + {hasMultipleLayers && } + + + + ); + })} + + {showMoreRows && ( + + + + + + )} +
+ {isNil(tableCaption) + ? `The table ${ + showMoreRows + ? `represents only ${rowLimit} of the ${tableLength} data points` + : `fully represents the dataset of ${tableLength} data point${tableLength > 1 ? 's' : ''}` + }` + : tableCaption} +
Small multiple titleDepthLabelParentValuePercentage
{panelTitle}{depth}{label}{parentName}{valueText}{percentage}
+ +
+
+ ); +}; + +const DEFAULT_SCREEN_READER_SUMMARY = { + a11ySettings: DEFAULT_A11Y_SETTINGS, + partitionData: { + isSmallMultiple: false, + hasMultipleLayers: false, + data: [], + }, + debug: false, +}; + +const mapStateToProps = (state: GlobalChartState): ScreenReaderPartitionTableProps => { + if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) { + return DEFAULT_SCREEN_READER_SUMMARY; + } + return { + a11ySettings: getA11ySettingsSelector(state), + partitionData: getScreenReaderDataSelector(state), + debug: getSettingsSpecSelector(state).debug, + }; +}; +/** @internal */ +export const ScreenReaderPartitionTable = memo(connect(mapStateToProps)(ScreenReaderPartitionTableComponent)); diff --git a/packages/charts/src/specs/settings.tsx b/packages/charts/src/specs/settings.tsx index c0eb9e1be0..5bc356b88b 100644 --- a/packages/charts/src/specs/settings.tsx +++ b/packages/charts/src/specs/settings.tsx @@ -586,6 +586,10 @@ export interface SettingsSpec extends Spec, LegendSpec { * @defaultValue true */ ariaUseDefaultSummary: boolean; + /** + * User can provide a table description of the data + */ + ariaTableCaption?: string; } /** @@ -648,7 +652,8 @@ export type DefaultSettingsProps = | 'legendPosition' | 'legendMaxDepth' | 'ariaUseDefaultSummary' - | 'ariaLabelHeadingLevel'; + | 'ariaLabelHeadingLevel' + | 'ariaTableCaption'; /** @public */ export type SettingsSpecProps = Partial>; diff --git a/packages/charts/src/state/selectors/get_accessibility_config.ts b/packages/charts/src/state/selectors/get_accessibility_config.ts index b6d0ae528a..684e2297cd 100644 --- a/packages/charts/src/state/selectors/get_accessibility_config.ts +++ b/packages/charts/src/state/selectors/get_accessibility_config.ts @@ -37,6 +37,7 @@ export type A11ySettings = { description?: string; descriptionId?: string; defaultSummaryId?: string; + tableCaption?: string; }; /** @internal */ @@ -48,7 +49,15 @@ export const DEFAULT_A11Y_SETTINGS: A11ySettings = { export const getA11ySettingsSelector = createCachedSelector( [getSettingsSpecSelector, getChartIdSelector], ( - { ariaDescription, ariaDescribedBy, ariaLabel, ariaLabelledBy, ariaUseDefaultSummary, ariaLabelHeadingLevel }, + { + ariaDescription, + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + ariaUseDefaultSummary, + ariaLabelHeadingLevel, + ariaTableCaption, + }, chartId, ) => { const defaultSummaryId = ariaUseDefaultSummary ? `${chartId}--defaultSummary` : undefined; @@ -69,6 +78,7 @@ export const getA11ySettingsSelector = createCachedSelector( // concat all the ids descriptionId: describeBy.length > 0 ? describeBy.join(' ') : undefined, defaultSummaryId, + tableCaption: ariaTableCaption, }; }, )(getChartIdSelector); diff --git a/stories/small_multiples/7_sunbursts.tsx b/stories/small_multiples/7_sunbursts.tsx index a3d47ba840..b84f509891 100644 --- a/stories/small_multiples/7_sunbursts.tsx +++ b/stories/small_multiples/7_sunbursts.tsx @@ -84,6 +84,7 @@ export const Example = () => { flatLegend={boolean('Flat legend', true)} theme={STORYBOOK_LIGHT_THEME} {...onElementListeners} + debug={boolean('Debug', false)} /> ( - - - d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} - layers={[ - { - groupByRollup: (d: Datum) => d.sitc1, - nodeLabel: (d: Datum) => productLookup[d].name, - fillLabel: { textInvertible: true }, - shape: { - fillColor: indexInterpolatedFillColor(interpolatorCET2s), +export const Example = () => { + const showDebug = boolean('show table for debugging', false); + return ( + + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { textInvertible: true }, + shape: { + fillColor: indexInterpolatedFillColor(interpolatorCET2s), + }, }, - }, - ]} - /> - -); + ]} + /> + + ); +}; diff --git a/stories/sunburst/8_sunburst_two_layers.tsx b/stories/sunburst/8_sunburst_two_layers.tsx index c922328837..aa103b89f9 100644 --- a/stories/sunburst/8_sunburst_two_layers.tsx +++ b/stories/sunburst/8_sunburst_two_layers.tsx @@ -17,6 +17,7 @@ * under the License. */ +import { boolean } from '@storybook/addon-knobs'; import React from 'react'; import { Chart, Datum, Partition, PartitionLayout, Settings } from '../../packages/charts/src'; @@ -25,58 +26,62 @@ import { mocks } from '../../packages/charts/src/mocks/hierarchical'; import { STORYBOOK_LIGHT_THEME } from '../shared'; import { countryLookup, indexInterpolatedFillColor, interpolatorCET2s, regionLookup } from '../utils/utils'; -export const Example = () => ( - - - d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} - layers={[ - { - groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.slice(0, 2), - nodeLabel: (d: any) => regionLookup[d].regionName, - fillLabel: { - fontFamily: 'Impact', - valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000000))}\u00A0Tn`, +export const Example = () => { + const showDebug = boolean('show table for debugging', false); + return ( + + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.slice(0, 2), + nodeLabel: (d: any) => regionLookup[d].regionName, + fillLabel: { + fontFamily: 'Impact', + valueFormatter: (d: number) => + `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000000))}\u00A0Tn`, + }, + shape: { + fillColor: (d) => + // pick color from color palette based on mean angle - rather distinct colors in the inner ring + indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []), + }, + }, + { + groupByRollup: (d: Datum) => d.dest, + nodeLabel: (d: any) => countryLookup[d].name, + shape: { + fillColor: (d) => + // pick color from color palette based on mean angle - related yet distinct colors in the outer ring + indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []), + }, }, - shape: { - fillColor: (d) => - // pick color from color palette based on mean angle - rather distinct colors in the inner ring - indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []), + ]} + config={{ + partitionLayout: PartitionLayout.sunburst, + linkLabel: { + maxCount: 0, + fontSize: 14, }, - }, - { - groupByRollup: (d: Datum) => d.dest, - nodeLabel: (d: any) => countryLookup[d].name, - shape: { - fillColor: (d) => - // pick color from color palette based on mean angle - related yet distinct colors in the outer ring - indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []), + fontFamily: 'Arial', + fillLabel: { + textInvertible: true, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`, + fontStyle: 'italic', }, - }, - ]} - config={{ - partitionLayout: PartitionLayout.sunburst, - linkLabel: { - maxCount: 0, - fontSize: 14, - }, - fontFamily: 'Arial', - fillLabel: { - textInvertible: true, - valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\u00A0Bn`, - fontStyle: 'italic', - }, - margin: { top: 0, bottom: 0, left: 0, right: 0 }, - minFontSize: 1, - idealFontSizeJump: 1.1, - outerSizeRatio: 0.95, - emptySizeRatio: 0, - circlePadding: 4, - backgroundColor: 'rgba(229,229,229,1)', - }} - /> - -); + margin: { top: 0, bottom: 0, left: 0, right: 0 }, + minFontSize: 1, + idealFontSizeJump: 1.1, + outerSizeRatio: 0.95, + emptySizeRatio: 0, + circlePadding: 4, + backgroundColor: 'rgba(229,229,229,1)', + }} + /> + + ); +};