Skip to content

Commit 80dfbb6

Browse files
authored
Merge pull request #454 from sbates-idrc/tilepanel-radiogroup
Change the tile panel into a `radiogroup` and require that painting starts within the scene (fixes #453)
2 parents 36a27ab + cb199ff commit 80dfbb6

3 files changed

Lines changed: 172 additions & 48 deletions

File tree

src/Scene.js

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { ReactComponent as StartIndicator } from './svg/StartIndicator.svg';
2626

2727
const startIndicatorWidth = 0.45;
2828

29-
type MousePosition = {
29+
type PointerPosition = {
3030
x: number,
3131
y: number
3232
};
@@ -173,7 +173,7 @@ class Scene extends React.Component<SceneProps, {}> {
173173
}
174174

175175
/* istanbul ignore next */
176-
getPositionFromSceneSvgMouseEvent(e: any): MousePosition {
176+
getPositionFromSceneSvgPointerEvent(e: any): PointerPosition {
177177
// $FlowFixMe: DOMPoint
178178
const clientPoint = new DOMPoint(e.clientX, e.clientY);
179179
const svgElem = e.currentTarget;
@@ -185,23 +185,36 @@ class Scene extends React.Component<SceneProps, {}> {
185185
}
186186

187187
/* istanbul ignore next */
188-
handleMouseDownSceneSvg = (e: any) => {
189-
const pos: MousePosition = this.getPositionFromSceneSvgMouseEvent(e);
190-
this.lastPaintX = pos.x;
191-
this.lastPaintY = pos.y;
192-
this.props.onPaintScene(pos.x, pos.y);
188+
handlePointerDownSceneSvg = (e: any) => {
189+
if (e.button === 0) {
190+
e.currentTarget.onpointermove = this.handlePaintMove;
191+
192+
// Capture the pointer events so that we can handle the case when
193+
// the pointerup event happens outside of the scene svg
194+
e.currentTarget.setPointerCapture(e.pointerId);
195+
196+
const pos: PointerPosition = this.getPositionFromSceneSvgPointerEvent(e);
197+
this.lastPaintX = pos.x;
198+
this.lastPaintY = pos.y;
199+
this.props.onPaintScene(pos.x, pos.y);
200+
}
193201
}
194202

195203
/* istanbul ignore next */
196-
handleMouseMoveSceneSvg = (e: any) => {
197-
const primaryButtonPressed = ((e.buttons % 2) === 1);
198-
if (primaryButtonPressed) {
199-
const pos: MousePosition = this.getPositionFromSceneSvgMouseEvent(e);
200-
if (pos.x !== this.lastPaintX || pos.y !== this.lastPaintY) {
201-
this.lastPaintX = pos.x;
202-
this.lastPaintY = pos.y;
203-
this.props.onPaintScene(pos.x, pos.y);
204-
}
204+
handlePointerUpSceneSvg = (e: any) => {
205+
e.currentTarget.onpointermove = null;
206+
e.currentTarget.releasePointerCapture(e.pointerId);
207+
}
208+
209+
/* istanbul ignore next */
210+
handlePaintMove = (e: any) => {
211+
const pos: PointerPosition = this.getPositionFromSceneSvgPointerEvent(e);
212+
if (this.props.dimensions.isXInRange(pos.x)
213+
&& this.props.dimensions.isYInRange(pos.y)
214+
&& (pos.x !== this.lastPaintX || pos.y !== this.lastPaintY)) {
215+
this.lastPaintX = pos.x;
216+
this.lastPaintY = pos.y;
217+
this.props.onPaintScene(pos.x, pos.y);
205218
}
206219
}
207220

@@ -252,8 +265,8 @@ class Scene extends React.Component<SceneProps, {}> {
252265
viewBox={`${minX} ${minY} ${width} ${height}`}
253266
ref={this.sceneSvgRef}
254267
aria-hidden={true}
255-
onMouseDown={this.handleMouseDownSceneSvg}
256-
onMouseMove={this.handleMouseMoveSceneSvg}
268+
onPointerDown={this.handlePointerDownSceneSvg}
269+
onPointerUp={this.handlePointerUpSceneSvg}
257270
>
258271
<defs>
259272
<clipPath id='Scene-clippath'>

src/TilePanel.js

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// @flow
22

3+
import * as C2lcMath from './C2lcMath';
34
import classNames from 'classnames';
45
import React from 'react';
5-
import { FormattedMessage, injectIntl } from 'react-intl';
6+
import { injectIntl } from 'react-intl';
67
import type { IntlShape } from 'react-intl';
78
import { getTileColor, getTileImage, getTileName, isTileCode } from './TileData';
89
import type { TileCode } from './TileData';
@@ -18,6 +19,7 @@ type TilePanelProps = {
1819

1920
class TilePanel extends React.PureComponent<TilePanelProps, {}> {
2021
tileCodes: Array<TileCode>;
22+
tileRefs: Map<TileCode, HTMLElement>;
2123

2224
constructor(props: TilePanelProps) {
2325
super(props);
@@ -40,29 +42,85 @@ class TilePanel extends React.PureComponent<TilePanelProps, {}> {
4042
'C',
4143
'D'
4244
];
45+
this.tileRefs = new Map();
4346
}
4447

45-
handleClickTile = (e: any) => {
46-
this.selectTile(e.currentTarget);
47-
};
48-
49-
handleMouseDownTile = (e: any) => {
50-
this.selectTile(e.currentTarget);
48+
handleKeyDownTile = (event: KeyboardEvent) => {
49+
switch(event.key) {
50+
case 'ArrowRight':
51+
case 'ArrowDown': {
52+
event.preventDefault();
53+
event.stopPropagation();
54+
// $FlowFixMe: dataset
55+
const index = this.tileCodes.indexOf(event.currentTarget.dataset.tilecode);
56+
if (index !== -1) {
57+
const newIndex = C2lcMath.wrap(
58+
0,
59+
this.tileCodes.length,
60+
index + 1
61+
);
62+
const newTileCode = this.tileCodes[newIndex];
63+
this.focusTile(newTileCode);
64+
this.props.onSelectTile(newTileCode);
65+
}
66+
break;
67+
}
68+
case 'ArrowLeft':
69+
case 'ArrowUp': {
70+
event.preventDefault();
71+
event.stopPropagation();
72+
// $FlowFixMe: dataset
73+
const index = this.tileCodes.indexOf(event.currentTarget.dataset.tilecode);
74+
if (index !== -1) {
75+
const newIndex = C2lcMath.wrap(
76+
0,
77+
this.tileCodes.length,
78+
index - 1
79+
);
80+
const newTileCode = this.tileCodes[newIndex];
81+
this.focusTile(newTileCode);
82+
this.props.onSelectTile(newTileCode);
83+
}
84+
break;
85+
}
86+
default:
87+
break;
88+
}
5189
};
5290

53-
selectTile(element: any) {
54-
const tileCode = element.dataset.tilecode;
91+
handleClickTile = (event: MouseEvent) => {
92+
// $FlowFixMe: dataset
93+
const tileCode = event.currentTarget.dataset.tilecode;
5594
if (isTileCode(tileCode)) {
5695
this.props.onSelectTile(((tileCode: any): TileCode));
5796
}
97+
};
98+
99+
setTileRef(tileCode: TileCode, element: ?HTMLElement) {
100+
if (element) {
101+
this.tileRefs.set(tileCode, element);
102+
}
103+
}
104+
105+
focusTile(tileCode: TileCode) {
106+
const element = this.tileRefs.get(tileCode);
107+
if (element && element.focus) {
108+
element.focus();
109+
}
58110
}
59111

60112
render() {
61113
const tiles = [];
62114

63-
for (const tileCode of this.tileCodes) {
115+
this.tileCodes.forEach((tileCode, i) => {
64116
const isSelected = (tileCode === this.props.selectedTile);
65117

118+
const tabIndex = (
119+
isSelected
120+
|| (i === 0 && (this.props.selectedTile == null
121+
|| !isTileCode(this.props.selectedTile)))
122+
) ? 0 : -1;
123+
66124
const tileClassName = classNames(
67125
'TilePanel__tile',
68126
isSelected && 'TilePanel__tile--selected'
@@ -77,12 +135,15 @@ class TilePanel extends React.PureComponent<TilePanelProps, {}> {
77135
tiles.push(
78136
<button
79137
className={tileClassName}
138+
role='radio'
80139
data-tilecode={tileCode}
81140
key={tileCode}
141+
ref={ (element) => this.setTileRef(tileCode, element) }
142+
tabIndex={tabIndex}
82143
aria-label={ariaLabel}
83-
aria-pressed={isSelected}
144+
aria-checked={isSelected}
145+
onKeyDown={this.handleKeyDownTile}
84146
onClick={this.handleClickTile}
85-
onMouseDown={this.handleMouseDownTile}
86147
>
87148
<div
88149
className='TilePanel__tileInner'
@@ -99,13 +160,14 @@ class TilePanel extends React.PureComponent<TilePanelProps, {}> {
99160
</div>
100161
</button>
101162
);
102-
}
163+
});
103164

104165
return (
105-
<div className='TilePanel'>
106-
<h2 className='sr-only'>
107-
<FormattedMessage id='TilePanel.heading' />
108-
</h2>
166+
<div
167+
className='TilePanel'
168+
role='radiogroup'
169+
aria-label={this.props.intl.formatMessage({ id: 'TilePanel.heading' })}
170+
>
109171
{tiles}
110172
</div>
111173
);

src/TilePanel.test.js

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { configure, mount } from 'enzyme';
44
import Adapter from 'enzyme-adapter-react-16';
55
import React from 'react';
66
import { IntlProvider } from 'react-intl';
7+
import { makeKeyDownEvent } from './TestUtils';
78
import type { TileCode } from './TileData';
89
import TilePanel from './TilePanel';
910
import type { ThemeName } from './types';
1011
import messages from './messages.json';
1112

1213
configure({ adapter: new Adapter()});
1314

15+
const selectedTileClass = 'TilePanel__tile--selected';
16+
1417
function createComponent(selectedTile: ?TileCode, theme: ThemeName) {
1518
const selectTileHandler = jest.fn();
1619

@@ -41,28 +44,74 @@ test('All expected tiles are rendered', () => {
4144
expect(wrapper.find('button')).toHaveLength(17);
4245
});
4346

44-
describe('If there is a selceted tile, it has the TilePanel__tile--selected class', () => {
45-
const selectedTileClassSelector = '.TilePanel__tile--selected';
46-
test('No selected tile', () => {
47-
const { wrapper } = createComponent(null, 'default');
48-
expect(wrapper.find(selectedTileClassSelector)).toHaveLength(0);
47+
test('When no tile is selected, no tile has the TilePanel__tile--selected class and all tiles has aria-checked=false', () => {
48+
expect.assertions(35);
49+
const { wrapper } = createComponent(null, 'default');
50+
const tiles = wrapper.find('button');
51+
expect(tiles).toHaveLength(17);
52+
tiles.forEach((tile) => {
53+
expect(tile.hasClass(selectedTileClass)).toBe(false);
54+
expect(tile.getDOMNode().getAttribute('aria-checked')).toBe('false');
4955
});
50-
test('Selected tile', () => {
51-
const { wrapper } = createComponent('0', 'default');
52-
expect(wrapper.find(selectedTileClassSelector)).toHaveLength(1);
56+
});
57+
58+
test('When no tile is selected, the first tile has tabIndex=0 and all others have tabIndex=-1', () => {
59+
expect.assertions(18);
60+
const { wrapper } = createComponent(null, 'default');
61+
const tiles = wrapper.find('button');
62+
expect(tiles).toHaveLength(17);
63+
tiles.forEach((tile, i) => {
64+
if (i === 0) {
65+
expect(tile.getDOMNode().getAttribute('tabIndex')).toBe('0');
66+
} else {
67+
expect(tile.getDOMNode().getAttribute('tabIndex')).toBe('-1');
68+
}
5369
});
5470
});
5571

56-
test('Clicking on a tile calls the provided callback', () => {
72+
test('When a tile is selected, only that tile has the TilePanel__tile--selected class, aria-checked=true, and tabIndex=0; all other tiles have aria-checked=false and tabIndex=-1', () => {
73+
expect.assertions(52);
74+
75+
const selectedTileCode = '2';
76+
// The tile with code '2' is at index 5 in the tile panel
77+
const expectedSelectedTileIndex = 5;
78+
79+
const { wrapper } = createComponent(selectedTileCode, 'default');
80+
const tiles = wrapper.find('button');
81+
expect(tiles).toHaveLength(17);
82+
tiles.forEach((tile, i) => {
83+
if (i === expectedSelectedTileIndex) {
84+
expect(tile.hasClass(selectedTileClass)).toBe(true);
85+
expect(tile.getDOMNode().getAttribute('aria-checked')).toBe('true');
86+
expect(tile.getDOMNode().getAttribute('tabIndex')).toBe('0');
87+
} else {
88+
expect(tile.hasClass(selectedTileClass)).toBe(false);
89+
expect(tile.getDOMNode().getAttribute('aria-checked')).toBe('false');
90+
expect(tile.getDOMNode().getAttribute('tabIndex')).toBe('-1');
91+
}
92+
});
93+
});
94+
95+
test.each([
96+
['ArrowRight', '0', '1'],
97+
['ArrowRight', 'D', '0'],
98+
['ArrowDown', '0', '1'],
99+
['ArrowDown', 'D', '0'],
100+
['ArrowLeft', '0', 'D'],
101+
['ArrowLeft', '1', '0'],
102+
['ArrowUp', '0', 'D'],
103+
['ArrowUp', '1', '0']
104+
])('%s on TileCode %s, expect TileCode %s', (key, startTileCode, expectedTileCode) => {
57105
const { wrapper, selectTileHandler } = createComponent(null, 'default');
58-
wrapper.find('button[data-tilecode="2"]').simulate('click');
106+
const tile = wrapper.find(`button[data-tilecode="${startTileCode}"]`);
107+
tile.simulate('keydown', makeKeyDownEvent(tile.getDOMNode(), key));
59108
expect(selectTileHandler.mock.calls.length).toBe(1);
60-
expect(selectTileHandler.mock.calls[0][0]).toBe('2');
109+
expect(selectTileHandler.mock.calls[0][0]).toBe(expectedTileCode);
61110
});
62111

63-
test('Mouse down on a tile calls the provided callback', () => {
112+
test('Clicking on a tile calls the provided callback', () => {
64113
const { wrapper, selectTileHandler } = createComponent(null, 'default');
65-
wrapper.find('button[data-tilecode="2"]').simulate('mousedown');
114+
wrapper.find('button[data-tilecode="2"]').simulate('click');
66115
expect(selectTileHandler.mock.calls.length).toBe(1);
67116
expect(selectTileHandler.mock.calls[0][0]).toBe('2');
68117
});

0 commit comments

Comments
 (0)