Skip to content

Commit fe9e4c3

Browse files
authored
fix: Keyboard navigation in area chart to allow highlighting all poin… (#547)
1 parent eecee87 commit fe9e4c3

File tree

9 files changed

+321
-17
lines changed

9 files changed

+321
-17
lines changed

jest.unit.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ module.exports = merge({}, tsPreset, cloudscapePreset, {
3636
},
3737
setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'jest', 'setup.js')],
3838
testRegex: '(/__tests__/.*(\\.|/)test)\\.[jt]sx?$',
39+
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'd.ts'],
3940
});

src/area-chart/__integ__/area-chart.test.ts

+77-3
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ describe('Keyboard navigation', () => {
238238
setupTest('#/light/area-chart/test', 'Linear latency chart', async page => {
239239
await page.focusPlot();
240240

241+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
242+
243+
await page.keys(['ArrowUp']);
244+
241245
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
242246

243247
await page.keys(['ArrowUp']);
@@ -246,9 +250,43 @@ describe('Keyboard navigation', () => {
246250

247251
await page.keys(['ArrowDown', 'ArrowDown']);
248252

253+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
254+
255+
await page.keys(['ArrowDown']);
256+
249257
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p90');
250258
})
251259
);
260+
261+
test(
262+
'maintains X coordinate after switching between focusing a single series and all series',
263+
setupTest('#/light/area-chart/test', 'Linear latency chart', async page => {
264+
await page.focusPlot();
265+
266+
await expect(page.getPopoverTitle()).resolves.toBe('1s');
267+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
268+
269+
await page.keys(['ArrowRight']);
270+
await expect(page.getPopoverTitle()).resolves.toBe('2s');
271+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
272+
273+
await page.keys(['ArrowDown']);
274+
await expect(page.getPopoverTitle()).resolves.toBe('2s');
275+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p90');
276+
277+
await page.keys(['ArrowRight']);
278+
await expect(page.getPopoverTitle()).resolves.toBe('3s');
279+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p90');
280+
281+
await page.keys(['ArrowUp']);
282+
await expect(page.getPopoverTitle()).resolves.toBe('3s');
283+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
284+
285+
await page.keys(['ArrowRight']);
286+
await expect(page.getPopoverTitle()).resolves.toBe('4s');
287+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
288+
})
289+
);
252290
});
253291

254292
describe('Focus delegation', () => {
@@ -259,13 +297,32 @@ describe('Focus delegation', () => {
259297

260298
await page.keys(['ArrowUp', 'ArrowRight', 'ArrowRight', 'Enter']);
261299

262-
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p60');
300+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
263301
await expect(page.getPopoverTitle()).resolves.toBe('3s');
264302
await expect(page.isPopoverPinned()).resolves.toBe(true);
265303

266304
await page.dismissPopover();
267305

268-
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p60');
306+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
307+
await expect(page.getPopoverTitle()).resolves.toBe('3s');
308+
await expect(page.isPopoverPinned()).resolves.toBe(false);
309+
})
310+
);
311+
312+
test(
313+
'when unpinning the popover the previously highlighted data point group is focused',
314+
setupTest('#/light/area-chart/test', 'Linear latency chart', async page => {
315+
await page.focusPlot();
316+
317+
await page.keys(['ArrowRight', 'ArrowRight', 'Enter']);
318+
319+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
320+
await expect(page.getPopoverTitle()).resolves.toBe('3s');
321+
await expect(page.isPopoverPinned()).resolves.toBe(true);
322+
323+
await page.dismissPopover();
324+
325+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
269326
await expect(page.getPopoverTitle()).resolves.toBe('3s');
270327
await expect(page.isPopoverPinned()).resolves.toBe(false);
271328
})
@@ -302,7 +359,7 @@ describe('Focus delegation', () => {
302359
await page.focusPlot();
303360

304361
await expect(page.getPopoverTitle()).resolves.toBe('1s');
305-
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
362+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
306363
})
307364
);
308365

@@ -341,11 +398,28 @@ describe('Controlled', () => {
341398
})
342399
);
343400

401+
test(
402+
'can use highlight X same as in uncontrolled chart',
403+
setupTest('#/light/area-chart/test', 'Controlled linear latency chart', async page => {
404+
await page.focusPlot();
405+
406+
await expect(page.getPopoverTitle()).resolves.toBe('1s');
407+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe(null);
408+
409+
await page.keys(['ArrowRight', 'ArrowUp']);
410+
411+
await expect(page.getPopoverTitle()).resolves.toBe('2s');
412+
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
413+
})
414+
);
415+
344416
test(
345417
'can use highlight series same as in uncontrolled chart',
346418
setupTest('#/light/area-chart/test', 'Controlled linear latency chart', async page => {
347419
await page.focusPlot();
348420

421+
await page.keys(['ArrowUp']);
422+
349423
await expect(page.getPopoverTitle()).resolves.toBe('1s');
350424
await expect(page.getHighlightedSeriesLabel()).resolves.toBe('p50');
351425

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
5+
import useChartModel, { UseChartModelProps } from '../model/use-chart-model';
6+
import { ElementWrapper } from '@cloudscape-design/test-utils-core/dom';
7+
import { ChartDataTypes } from '../../internal/components/cartesian-chart/interfaces';
8+
import { act, render } from '@testing-library/react';
9+
import { AreaChartProps } from '../interfaces';
10+
import { KeyCode } from '../../internal/keycode';
11+
import { useReaction } from '../model/async-store';
12+
import { ChartModel } from '../model';
13+
import PlotPoint = ChartModel.PlotPoint;
14+
15+
class UseChartModelWrapper extends ElementWrapper {
16+
static selectors = {
17+
highlightedPoint: 'highlighted-point',
18+
highlightedSeries: 'highlighted-series',
19+
highlightedX: 'highlighted-x',
20+
};
21+
22+
findHighlightedPoint() {
23+
return this.findByClassName(UseChartModelWrapper.selectors.highlightedPoint);
24+
}
25+
26+
findHighlightedSeries() {
27+
return this.findByClassName(UseChartModelWrapper.selectors.highlightedSeries);
28+
}
29+
30+
findHighlightedX() {
31+
return this.findByClassName(UseChartModelWrapper.selectors.highlightedX);
32+
}
33+
}
34+
35+
function RenderChartModelHook(props: UseChartModelProps<ChartDataTypes>) {
36+
const [highlightedSeries, setHighlightedSeries] = useState<null | AreaChartProps.Series<ChartDataTypes>>(null);
37+
const [visibleSeries, setVisibleSeries] = useState(props.visibleSeries);
38+
const [highlightedPoint, setHighlightedPoint] = useState<PlotPoint<ChartDataTypes> | null>(null);
39+
const [highlightedX, setHighlightedX] = useState<readonly PlotPoint<ChartDataTypes>[] | null>(null);
40+
41+
const { computed, handlers, interactions } = useChartModel({
42+
...props,
43+
highlightedSeries,
44+
setHighlightedSeries,
45+
visibleSeries,
46+
setVisibleSeries,
47+
});
48+
49+
useReaction(interactions, state => state.highlightedPoint, setHighlightedPoint);
50+
useReaction(interactions, state => state.highlightedX, setHighlightedX);
51+
52+
return (
53+
<div
54+
onFocus={event => {
55+
handlers.onSVGFocus(event, 'keyboard');
56+
}}
57+
onKeyDown={handlers.onSVGKeyDown}
58+
tabIndex={-1}
59+
>
60+
<span>{computed.xTicks.length}</span>
61+
<span className={UseChartModelWrapper.selectors.highlightedPoint}>
62+
{highlightedPoint ? `${highlightedPoint.x},${highlightedPoint.value}` : null}
63+
</span>
64+
<span className={UseChartModelWrapper.selectors.highlightedSeries}>{highlightedSeries?.title}</span>
65+
<span className={UseChartModelWrapper.selectors.highlightedX}>
66+
{highlightedX === null ? null : highlightedX[0].x}
67+
</span>
68+
<span>{visibleSeries[0].title}</span>
69+
</div>
70+
);
71+
}
72+
73+
function renderChartModelHook(props: UseChartModelProps<ChartDataTypes>) {
74+
const { rerender, container } = render(<RenderChartModelHook {...props} />);
75+
const wrapper = new UseChartModelWrapper(container.firstChild as HTMLElement);
76+
return { rerender, wrapper };
77+
}
78+
79+
describe('useChartModel', () => {
80+
const series: readonly AreaChartProps.Series<ChartDataTypes>[] = [
81+
{
82+
type: 'area',
83+
title: 'series1',
84+
color: 'orange',
85+
data: [
86+
{ x: 0, y: 1 },
87+
{ x: 1, y: 3 },
88+
{ x: 2, y: 5 },
89+
{ x: 3, y: 7 },
90+
],
91+
},
92+
{
93+
type: 'area',
94+
title: 'series2',
95+
color: 'blue',
96+
data: [
97+
{ x: 0, y: 2 },
98+
{ x: 1, y: 4 },
99+
{ x: 2, y: 6 },
100+
{ x: 3, y: 8 },
101+
],
102+
},
103+
];
104+
105+
describe('keyboard navigation', () => {
106+
it('cycles between the different series', () => {
107+
const { wrapper } = renderChartModelHook({
108+
height: 0,
109+
highlightedSeries: null,
110+
setHighlightedSeries: (_series: AreaChartProps.Series<ChartDataTypes> | null) => _series,
111+
setVisibleSeries: (_series: readonly AreaChartProps.Series<ChartDataTypes>[]) => _series,
112+
width: 0,
113+
xDomain: undefined,
114+
xScaleType: 'linear',
115+
yScaleType: 'linear',
116+
externalSeries: series,
117+
visibleSeries: series,
118+
});
119+
act(() => wrapper.focus());
120+
121+
// Show all series
122+
expect(wrapper.findHighlightedX()?.getElement()).toHaveTextContent('0');
123+
expect(wrapper.findHighlightedPoint()?.getElement()).toBeEmptyDOMElement();
124+
125+
// Show first series (from the end)
126+
act(() => wrapper.keydown(KeyCode.down));
127+
expect(wrapper.findHighlightedPoint()?.getElement()).toHaveTextContent('0,2');
128+
129+
// Show second series (from the end)
130+
act(() => wrapper.keydown(KeyCode.down));
131+
expect(wrapper.findHighlightedPoint()?.getElement()).toHaveTextContent('0,1');
132+
133+
// Loop back to show all series
134+
act(() => wrapper.keydown(KeyCode.down));
135+
expect(wrapper.findHighlightedX()?.getElement()).toHaveTextContent('0');
136+
expect(wrapper.findHighlightedPoint()?.getElement()).toBeEmptyDOMElement();
137+
});
138+
139+
describe('navigation across X axis', () => {
140+
it('highlights next or previous point in the data series when a series is focused', () => {
141+
const { wrapper } = renderChartModelHook({
142+
height: 0,
143+
highlightedSeries: null,
144+
setHighlightedSeries: (_series: AreaChartProps.Series<ChartDataTypes> | null) => _series,
145+
setVisibleSeries: (_series: readonly AreaChartProps.Series<ChartDataTypes>[]) => _series,
146+
width: 0,
147+
xDomain: undefined,
148+
xScaleType: 'linear',
149+
yScaleType: 'linear',
150+
externalSeries: series,
151+
visibleSeries: series,
152+
});
153+
act(() => wrapper.focus());
154+
155+
act(() => wrapper.keydown(KeyCode.down));
156+
expect(wrapper.findHighlightedPoint()?.getElement()).toHaveTextContent('0,2');
157+
158+
act(() => wrapper.keydown(KeyCode.right));
159+
expect(wrapper.findHighlightedPoint()?.getElement()).toHaveTextContent('1,4');
160+
});
161+
162+
it('highlights next or previous X when no series is focused', () => {
163+
const { wrapper } = renderChartModelHook({
164+
height: 0,
165+
highlightedSeries: null,
166+
setHighlightedSeries: (_series: AreaChartProps.Series<ChartDataTypes> | null) => _series,
167+
setVisibleSeries: (_series: readonly AreaChartProps.Series<ChartDataTypes>[]) => _series,
168+
width: 0,
169+
xDomain: undefined,
170+
xScaleType: 'linear',
171+
yScaleType: 'linear',
172+
externalSeries: series,
173+
visibleSeries: series,
174+
});
175+
act(() => wrapper.focus());
176+
expect(wrapper.findHighlightedX()?.getElement()).toHaveTextContent('0');
177+
178+
act(() => wrapper.keydown(KeyCode.right));
179+
expect(wrapper.findHighlightedX()?.getElement()).toHaveTextContent('1');
180+
});
181+
});
182+
});
183+
});

src/area-chart/chart-container.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ function ChartContainer<T extends AreaChartProps.DataTypes>({
7979

8080
const mergedRef = useMergeRefs(containerWidthRef, model.refs.container);
8181

82+
const isPointHighlighted = model.interactions.get().highlightedPoint !== null;
83+
8284
return (
8385
<div className={styles['chart-container']} ref={mergedRef}>
8486
<AxisLabel axis="y" position="left" title={yTitle} />
@@ -102,7 +104,8 @@ function ChartContainer<T extends AreaChartProps.DataTypes>({
102104
ariaDescription={ariaDescription}
103105
ariaRoleDescription={chartAriaRoleDescription}
104106
activeElementKey={!highlightDetails?.isPopoverPinned && highlightDetails?.activeLabel}
105-
activeElementRef={highlightedPointRef}
107+
activeElementRef={isPointHighlighted ? highlightedPointRef : model.refs.verticalMarker}
108+
activeElementFocusOffset={isPointHighlighted ? 3 : { x: 8, y: 0 }}
106109
isClickable={!highlightDetails?.isPopoverPinned}
107110
onMouseMove={model.handlers.onSVGMouseMove}
108111
onMouseOut={model.handlers.onSVGMouseOut}

src/area-chart/model/interactions-store.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default class InteractionsStore<T extends AreaChartProps.DataTypes> exten
4242
highlightedX: points,
4343
highlightedPoint: null,
4444
highlightedSeries: null,
45+
legendSeries: null,
4546
}));
4647
}
4748

0 commit comments

Comments
 (0)