Skip to content

Commit 1e27408

Browse files
committed
Make CheckboxTree accessible.
1. The keyboard can now be used to move through the tree, expand and collapse nodes: * Home / end moves to the first and last visible node, respectively. * Up / down arrows moves to the previous / next visible node. * Right arrow expands a collapsed node, if focus is on a collapsed parent. If focus is on an expanded parent, move to its first child. * Left arrow collapses the node if focus is on an expanded parent. Otherwise, focus is moved to the parent of the currently focused node. * First letter navigation: for example, press R to move focus to the next node who's label starts with R. * Space toggles selection, as expected for a checkbox. This is implemented by computing an in-order traversal of visible nodes updated each render which greatly simplifies computation for focus movements. Focus is managed by using the [roving tabindex pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex). * Each TreeNode takes in a new property, `hasFocus` which is initialized to `false` on initial render. This causes each tree item to have `tabindex=-1` set, which excludes them from tab order, but allows them to be programatically focused. * On initial focus of the top-level `CheckboxTree` component, we initialize the currently focused node index to 0. This causes the first tree node's `hasFocus` to be set to `true`, which sets `tabIndex=0`, so it is included in tab order. `TreeNode`'s `componentDidUpdate` fires a focus event when it is supposed to gain focus. * Other key presses manipulate the currently focused index, which causes the element with `tabindex=0` to move about, hence the roving tabindex. 2. Add the necessary aria attributes for screen readers to correctly read the state of the tree, whether a node is expanded/collapsed, checked etc. For more information, see https://www.w3.org/TR/wai-aria-practices-1.1/#TreeView
1 parent ab0478c commit 1e27408

File tree

5 files changed

+184
-19
lines changed

5 files changed

+184
-19
lines changed

src/js/CheckboxTree.js

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ import languageShape from './shapes/languageShape';
1212
import listShape from './shapes/listShape';
1313
import nodeShape from './shapes/nodeShape';
1414

15+
const SUPPORTED_KEYS = [
16+
'ArrowUp',
17+
'ArrowDown',
18+
'ArrowLeft',
19+
'ArrowRight',
20+
'End',
21+
'Home',
22+
'Enter',
23+
' ',
24+
];
25+
26+
// Clamp a number so that it is within the range [min, max]
27+
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
28+
1529
class CheckboxTree extends React.Component {
1630
static propTypes = {
1731
nodes: PropTypes.arrayOf(nodeShape).isRequired,
@@ -87,6 +101,7 @@ class CheckboxTree extends React.Component {
87101
});
88102

89103
this.state = {
104+
focusedNodeIndex: null,
90105
id: props.id || `rct-${nanoid(7)}`,
91106
model,
92107
prevProps: props,
@@ -97,6 +112,8 @@ class CheckboxTree extends React.Component {
97112
this.onNodeClick = this.onNodeClick.bind(this);
98113
this.onExpandAll = this.onExpandAll.bind(this);
99114
this.onCollapseAll = this.onCollapseAll.bind(this);
115+
this.onFocus = this.onFocus.bind(this);
116+
this.onKeyDown = this.onKeyDown.bind(this);
100117
}
101118

102119
// eslint-disable-next-line react/sort-comp
@@ -158,6 +175,86 @@ class CheckboxTree extends React.Component {
158175
this.expandAllNodes(false);
159176
}
160177

178+
onFocus() {
179+
const isFirstFocus = this.state.focusedNodeIndex === null;
180+
if (isFirstFocus) {
181+
this.setState({ focusedNodeIndex: 0 });
182+
}
183+
}
184+
185+
onKeyDown(e) {
186+
const keyEligibleForFirstLetterNavigation = e.key.length === 1 &&
187+
!e.ctrlKey && !e.metaKey && !e.altKey;
188+
// abort early so that we don't try to intercept common browser keystrokes like alt+d
189+
if (!SUPPORTED_KEYS.includes(e.key) && !keyEligibleForFirstLetterNavigation) {
190+
return;
191+
}
192+
193+
const { focusedNodeIndex, model } = this.state;
194+
const currentlyFocusedNode = model.getNode(this.visibleNodes[focusedNodeIndex || 0]);
195+
let newFocusedNodeIndex = focusedNodeIndex || 0;
196+
const isExpandingEnabled = !this.props.expandDisabled && !this.props.disabled;
197+
198+
e.preventDefault(); // disable built-in scrolling
199+
switch (e.key) {
200+
case 'ArrowDown':
201+
newFocusedNodeIndex += 1;
202+
break;
203+
case 'ArrowUp':
204+
newFocusedNodeIndex -= 1;
205+
break;
206+
case 'Home':
207+
newFocusedNodeIndex = 0;
208+
break;
209+
case 'End':
210+
newFocusedNodeIndex = this.visibleNodes.length - 1;
211+
break;
212+
case 'ArrowRight':
213+
if (currentlyFocusedNode && currentlyFocusedNode.isParent) {
214+
if (currentlyFocusedNode.expanded) {
215+
// we can increment focused index to get the first child
216+
// because visibleNodes is an in-order traversal of the tree
217+
newFocusedNodeIndex += 1;
218+
} else if (isExpandingEnabled) {
219+
// expand the currently focused node
220+
this.onExpand({ value: currentlyFocusedNode.value, expanded: true });
221+
}
222+
}
223+
break;
224+
case 'ArrowLeft':
225+
if (currentlyFocusedNode && currentlyFocusedNode.isParent) {
226+
if (currentlyFocusedNode.expanded && isExpandingEnabled) {
227+
// collapse the currently focused node
228+
this.onExpand({ value: currentlyFocusedNode.value, expanded: false });
229+
} else {
230+
// Move focus to the parent of the current node, if any
231+
// parent is the first element to the left of the currently focused element
232+
// with a lower tree depth since visibleNodes is an in-order traversal
233+
const parent = this.visibleNodes.slice(0, focusedNodeIndex)
234+
.reverse()
235+
// eslint-disable-next-line max-len
236+
.find(val => model.getNode(val).treeDepth < currentlyFocusedNode.treeDepth);
237+
if (parent) {
238+
newFocusedNodeIndex = this.visibleNodes.indexOf(parent);
239+
}
240+
}
241+
}
242+
break;
243+
default:
244+
if (keyEligibleForFirstLetterNavigation) {
245+
const next = this.visibleNodes.slice((focusedNodeIndex || 0) + 1)
246+
.find(val => model.getNode(val).label.startsWith(e.key));
247+
if (next) {
248+
newFocusedNodeIndex = this.visibleNodes.indexOf(next);
249+
}
250+
}
251+
break;
252+
}
253+
254+
newFocusedNodeIndex = clamp(newFocusedNodeIndex, 0, this.visibleNodes.length - 1);
255+
this.setState({ focusedNodeIndex: newFocusedNodeIndex });
256+
}
257+
161258
expandAllNodes(expand = true) {
162259
const { onExpand } = this.props;
163260

@@ -207,10 +304,17 @@ class CheckboxTree extends React.Component {
207304
showNodeTitle,
208305
showNodeIcon,
209306
} = this.props;
210-
const { id, model } = this.state;
307+
const { focusedNodeIndex, id, model } = this.state;
211308
const { icons: defaultIcons } = CheckboxTree.defaultProps;
212309

213310
const treeNodes = nodes.map((node) => {
311+
// Render only if parent is expanded or if there is no root parent
312+
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;
313+
if (!parentExpanded) {
314+
return null;
315+
}
316+
317+
this.visibleNodes.push(node.value);
214318
const key = node.value;
215319
const flatNode = model.getNode(node.value);
216320
const children = flatNode.isParent ? this.renderTreeNodes(node.children, node) : null;
@@ -223,13 +327,6 @@ class CheckboxTree extends React.Component {
223327
// Show checkbox only if this is a leaf node or showCheckbox is true
224328
const showCheckbox = onlyLeafCheckboxes ? flatNode.isLeaf : flatNode.showCheckbox;
225329

226-
// Render only if parent is expanded or if there is no root parent
227-
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;
228-
229-
if (!parentExpanded) {
230-
return null;
231-
}
232-
233330
return (
234331
<TreeNode
235332
key={key}
@@ -239,6 +336,7 @@ class CheckboxTree extends React.Component {
239336
expandDisabled={expandDisabled}
240337
expandOnClick={expandOnClick}
241338
expanded={flatNode.expanded}
339+
hasFocus={this.visibleNodes[focusedNodeIndex] === node.value}
242340
icon={node.icon}
243341
icons={{ ...defaultIcons, ...icons }}
244342
label={node.label}
@@ -261,7 +359,7 @@ class CheckboxTree extends React.Component {
261359
});
262360

263361
return (
264-
<ol>
362+
<ol role="presentation">
265363
{treeNodes}
266364
</ol>
267365
);
@@ -327,6 +425,9 @@ class CheckboxTree extends React.Component {
327425

328426
render() {
329427
const { disabled, nodes, nativeCheckboxes } = this.props;
428+
const { focusedNodeIndex } = this.state;
429+
const isFirstFocus = focusedNodeIndex === null;
430+
this.visibleNodes = []; // an in-order traversal of the tree for keyboard support
330431
const treeNodes = this.renderTreeNodes(nodes);
331432

332433
const className = classNames({
@@ -339,7 +440,15 @@ class CheckboxTree extends React.Component {
339440
<div className={className}>
340441
{this.renderExpandAll()}
341442
{this.renderHiddenInput()}
342-
{treeNodes}
443+
<div
444+
onFocus={this.onFocus}
445+
onKeyDown={this.onKeyDown}
446+
role="tree"
447+
// Only include top-level node in tab order if it has never gained focus before
448+
tabIndex={isFirstFocus ? 0 : -1}
449+
>
450+
{treeNodes}
451+
</div>
343452
</div>
344453
);
345454
}

src/js/NativeCheckbox.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ class NativeCheckbox extends React.PureComponent {
3030
// Remove property that does not exist in HTML
3131
delete props.indeterminate;
3232

33-
return <input {...props} ref={(c) => { this.checkbox = c; }} type="checkbox" />;
33+
// Since we already implement space toggling selection,
34+
// the native checkbox no longer needs to be in the accessibility tree and in tab order
35+
// I.e, this is purely for visual rendering
36+
return <input {...props} ref={(c) => { this.checkbox = c; }} type="checkbox" aria-hidden tabIndex={-1} />;
3437
}
3538
}
3639

src/js/TreeNode.js

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class TreeNode extends React.Component {
1313
disabled: PropTypes.bool.isRequired,
1414
expandDisabled: PropTypes.bool.isRequired,
1515
expanded: PropTypes.bool.isRequired,
16+
hasFocus: PropTypes.bool.isRequired,
1617
icons: iconsShape.isRequired,
1718
isLeaf: PropTypes.bool.isRequired,
1819
isParent: PropTypes.bool.isRequired,
@@ -50,11 +51,23 @@ class TreeNode extends React.Component {
5051
constructor(props) {
5152
super(props);
5253

54+
this.nodeRef = React.createRef();
55+
56+
this.componentDidUpdate = this.componentDidUpdate.bind(this);
5357
this.onCheck = this.onCheck.bind(this);
5458
this.onClick = this.onClick.bind(this);
59+
this.onKeyDown = this.onKeyDown.bind(this);
5560
this.onExpand = this.onExpand.bind(this);
5661
}
5762

63+
componentDidUpdate(prevProps) {
64+
// Move focus for keyboard users
65+
const isReceivingFocus = this.props.hasFocus && !prevProps.hasFocus;
66+
if (isReceivingFocus) {
67+
this.nodeRef.current.focus();
68+
}
69+
}
70+
5871
onCheck() {
5972
const { value, onCheck } = this.props;
6073

@@ -77,6 +90,16 @@ class TreeNode extends React.Component {
7790
onClick({ value, checked: this.getCheckState({ toggle: false }) });
7891
}
7992

93+
onKeyDown(e) {
94+
if (e.key === ' ') {
95+
e.preventDefault(); // prevent scrolling
96+
e.stopPropagation(); // prevent parent nodes from toggling their checked state
97+
if (!this.props.disabled) {
98+
this.onCheck();
99+
}
100+
}
101+
}
102+
80103
onExpand() {
81104
const { expanded, value, onExpand } = this.props;
82105

@@ -117,10 +140,13 @@ class TreeNode extends React.Component {
117140

118141
return (
119142
<Button
143+
// hide this button from the accessibility tree, as there is full keyboard control
144+
aria-hidden
120145
className="rct-collapse rct-collapse-btn"
121146
disabled={expandDisabled}
122147
title={lang.toggle}
123148
onClick={this.onExpand}
149+
tabIndex={-1}
124150
>
125151
{this.renderCollapseIcon()}
126152
</Button>
@@ -178,21 +204,24 @@ class TreeNode extends React.Component {
178204
const { onClick, title } = this.props;
179205
const clickable = onClick !== null;
180206

207+
// Disable the lints about this control not being accessible
208+
// We already provide full keyboard control, so this is clickable for mouse users
209+
// eslint-disable-next-line max-len
210+
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
181211
return (
182212
<span className="rct-bare-label" title={title}>
183213
{clickable ? (
184214
<span
185215
className="rct-node-clickable"
186216
onClick={this.onClick}
187-
onKeyPress={this.onClick}
188-
role="button"
189-
tabIndex={0}
190217
>
191218
{children}
192219
</span>
193220
) : children}
194221
</span>
195222
);
223+
// eslint-disable-next-line max-len
224+
/* eslint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
196225
}
197226

198227
renderCheckboxLabel(children) {
@@ -226,13 +255,13 @@ class TreeNode extends React.Component {
226255

227256
if (clickable) {
228257
render.push((
258+
// We can disable the lint here, since keyboard functionality is already provided
259+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
229260
<span
230261
key={1}
231262
className="rct-node-clickable"
232263
onClick={this.onClick}
233264
onKeyPress={this.onClick}
234-
role="link"
235-
tabIndex={0}
236265
>
237266
{children}
238267
</span>
@@ -267,11 +296,18 @@ class TreeNode extends React.Component {
267296
return null;
268297
}
269298

270-
return this.props.children;
299+
return this.props.isParent ? (
300+
<div role="group">
301+
{this.props.children}
302+
</div>
303+
) : (
304+
this.props.children
305+
);
271306
}
272307

273308
render() {
274309
const {
310+
checked,
275311
className,
276312
disabled,
277313
expanded,
@@ -285,9 +321,22 @@ class TreeNode extends React.Component {
285321
'rct-node-collapsed': !isLeaf && !expanded,
286322
'rct-disabled': disabled,
287323
}, className);
324+
let ariaChecked = checked === 1 ? 'true' : 'false';
325+
if (checked === 2) {
326+
ariaChecked = 'mixed';
327+
}
288328

289329
return (
290-
<li className={nodeClass}>
330+
<li
331+
aria-checked={ariaChecked}
332+
aria-disabled={disabled}
333+
aria-expanded={this.props.isParent ? expanded || false : null}
334+
className={nodeClass}
335+
onKeyDown={this.onKeyDown}
336+
ref={this.nodeRef}
337+
role="treeitem"
338+
tabIndex={this.props.hasFocus ? 0 : -1}
339+
>
291340
<span className="rct-text">
292341
{this.renderCollapseButton()}
293342
{this.renderLabel()}

src/scss/react-checkbox-tree.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ $rct-clickable-hover: rgba($rct-icon-color, .1) !default;
55
$rct-clickable-focus: rgba($rct-icon-color, .2) !default;
66

77
.react-checkbox-tree {
8+
// Unsure why these 2 lines cause the tree items to not render visually, once I added a div with role="tree" to wrap the tree
9+
// would appreciate help to fix
10+
/*
811
display: flex;
912
flex-direction: row-reverse;
13+
*/
1014
font-size: 16px;
1115

1216
> ol {

test/CheckboxTree.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ describe('<CheckboxTree />', () => {
178178

179179
assert.deepEqual(
180180
wrapper.find(TreeNode).prop('children').props,
181-
{ children: [null, null] },
181+
{ children: [null, null], role: 'presentation' },
182182
);
183183
});
184184
});

0 commit comments

Comments
 (0)