Skip to content

Commit

Permalink
Make CheckboxTree accessible.
Browse files Browse the repository at this point in the history
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 pre-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
  • Loading branch information
Neurrone committed Aug 13, 2019
1 parent ab0478c commit b03f44b
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 14 deletions.
128 changes: 123 additions & 5 deletions src/js/CheckboxTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import languageShape from './shapes/languageShape';
import listShape from './shapes/listShape';
import nodeShape from './shapes/nodeShape';

const SUPPORTED_KEYS = [
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'End',
'Home',
'Enter',
' ',
];

// Clamp a number so that it is within the range [min, max]
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);

class CheckboxTree extends React.Component {
static propTypes = {
nodes: PropTypes.arrayOf(nodeShape).isRequired,
Expand Down Expand Up @@ -87,6 +101,7 @@ class CheckboxTree extends React.Component {
});

this.state = {
focusedNodeIndex: null,
id: props.id || `rct-${nanoid(7)}`,
model,
prevProps: props,
Expand All @@ -97,6 +112,8 @@ class CheckboxTree extends React.Component {
this.onNodeClick = this.onNodeClick.bind(this);
this.onExpandAll = this.onExpandAll.bind(this);
this.onCollapseAll = this.onCollapseAll.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
}

// eslint-disable-next-line react/sort-comp
Expand Down Expand Up @@ -158,6 +175,92 @@ class CheckboxTree extends React.Component {
this.expandAllNodes(false);
}

onFocus() {
const isFirstFocus = this.state.focusedNodeIndex === null;
if (isFirstFocus) {
this.setState({ focusedNodeIndex: 0 });
}
}

onKeyDown(e) {
const keyEligibleForFirstLetterNavigation = e.key.length === 1 &&
!e.ctrlKey && !e.metaKey && !e.altKey;
// abort early so that we don't try to intercept common browser keystrokes like alt+d
if (!SUPPORTED_KEYS.includes(e.key) && !keyEligibleForFirstLetterNavigation) {
return;
}

const { focusedNodeIndex, model } = this.state;
const currentlyFocusedNode = model.getNode(this.visibleNodes[focusedNodeIndex || 0]);
let newFocusedNodeIndex = focusedNodeIndex || 0;
const isExpandingEnabled = !this.props.expandDisabled && !this.props.disabled;

e.preventDefault(); // disable built-in scrolling
switch (e.key) {
case 'ArrowDown':
newFocusedNodeIndex += 1;
break;
case 'ArrowUp':
newFocusedNodeIndex -= 1;
break;
case 'Home':
newFocusedNodeIndex = 0;
break;
case 'End':
newFocusedNodeIndex = this.visibleNodes.length - 1;
break;
case 'ArrowRight':
if (currentlyFocusedNode && currentlyFocusedNode.isParent) {
if (currentlyFocusedNode.expanded) {
// we can increment focused index to get the first child
// because visibleNodes is an pre-order traversal of the tree
newFocusedNodeIndex += 1;
} else if (isExpandingEnabled) {
// expand the currently focused node
this.onExpand({ value: currentlyFocusedNode.value, expanded: true });
}
}
break;
case 'ArrowLeft':
if (!currentlyFocusedNode) {
return;
}
if (currentlyFocusedNode.isParent && currentlyFocusedNode.expanded &&
isExpandingEnabled) {
// collapse the currently focused node
this.onExpand({ value: currentlyFocusedNode.value, expanded: false });
} else {
// Move focus to the parent of the current node, if any
// parent is the first element to the left of the currently focused element
// with a lower tree depth since visibleNodes is an pre-order traversal
const parent = this.visibleNodes.slice(0, focusedNodeIndex)
.reverse()
.find(val => model.getNode(val).treeDepth < currentlyFocusedNode.treeDepth);
if (parent) {
newFocusedNodeIndex = this.visibleNodes.indexOf(parent);
}
}
break;
default:
if (keyEligibleForFirstLetterNavigation) {
const next = this.visibleNodes.slice((focusedNodeIndex || 0) + 1)
.find((val) => {
const { label } = model.getNode(val);
// for now, we only support first-letter nav to
// nodes with string labels, not jsx elements
return label.startsWith ? label.startsWith(e.key) : false;
});
if (next) {
newFocusedNodeIndex = this.visibleNodes.indexOf(next);
}
}
break;
}

newFocusedNodeIndex = clamp(newFocusedNodeIndex, 0, this.visibleNodes.length - 1);
this.setState({ focusedNodeIndex: newFocusedNodeIndex });
}

expandAllNodes(expand = true) {
const { onExpand } = this.props;

Expand Down Expand Up @@ -207,10 +310,15 @@ class CheckboxTree extends React.Component {
showNodeTitle,
showNodeIcon,
} = this.props;
const { id, model } = this.state;
const { focusedNodeIndex, id, model } = this.state;
const { icons: defaultIcons } = CheckboxTree.defaultProps;

const treeNodes = nodes.map((node) => {
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;
if (parentExpanded) {
// visible only if parent is expanded or if there is no root parent
this.visibleNodes.push(node.value);
}
const key = node.value;
const flatNode = model.getNode(node.value);
const children = flatNode.isParent ? this.renderTreeNodes(node.children, node) : null;
Expand All @@ -224,8 +332,6 @@ class CheckboxTree extends React.Component {
const showCheckbox = onlyLeafCheckboxes ? flatNode.isLeaf : flatNode.showCheckbox;

// Render only if parent is expanded or if there is no root parent
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;

if (!parentExpanded) {
return null;
}
Expand All @@ -239,6 +345,7 @@ class CheckboxTree extends React.Component {
expandDisabled={expandDisabled}
expandOnClick={expandOnClick}
expanded={flatNode.expanded}
hasFocus={this.visibleNodes[focusedNodeIndex] === node.value}
icon={node.icon}
icons={{ ...defaultIcons, ...icons }}
label={node.label}
Expand All @@ -261,7 +368,7 @@ class CheckboxTree extends React.Component {
});

return (
<ol>
<ol role="presentation">
{treeNodes}
</ol>
);
Expand Down Expand Up @@ -327,6 +434,9 @@ class CheckboxTree extends React.Component {

render() {
const { disabled, nodes, nativeCheckboxes } = this.props;
const { focusedNodeIndex } = this.state;
const isFirstFocus = focusedNodeIndex === null;
this.visibleNodes = []; // an pre-order traversal of the tree for keyboard support
const treeNodes = this.renderTreeNodes(nodes);

const className = classNames({
Expand All @@ -339,7 +449,15 @@ class CheckboxTree extends React.Component {
<div className={className}>
{this.renderExpandAll()}
{this.renderHiddenInput()}
{treeNodes}
<div
onFocus={this.onFocus}
onKeyDown={this.onKeyDown}
role="tree"
// Only include top-level node in tab order if it has never gained focus before
tabIndex={isFirstFocus ? 0 : -1}
>
{treeNodes}
</div>
</div>
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/js/NativeCheckbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ class NativeCheckbox extends React.PureComponent {
// Remove property that does not exist in HTML
delete props.indeterminate;

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

Expand Down
63 changes: 56 additions & 7 deletions src/js/TreeNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class TreeNode extends React.Component {
disabled: PropTypes.bool.isRequired,
expandDisabled: PropTypes.bool.isRequired,
expanded: PropTypes.bool.isRequired,
hasFocus: PropTypes.bool.isRequired,
icons: iconsShape.isRequired,
isLeaf: PropTypes.bool.isRequired,
isParent: PropTypes.bool.isRequired,
Expand Down Expand Up @@ -50,11 +51,23 @@ class TreeNode extends React.Component {
constructor(props) {
super(props);

this.nodeRef = React.createRef();

this.componentDidUpdate = this.componentDidUpdate.bind(this);
this.onCheck = this.onCheck.bind(this);
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onExpand = this.onExpand.bind(this);
}

componentDidUpdate(prevProps) {
// Move focus for keyboard users
const isReceivingFocus = this.props.hasFocus && !prevProps.hasFocus;
if (isReceivingFocus) {
this.nodeRef.current.focus();
}
}

onCheck() {
const { value, onCheck } = this.props;

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

onKeyDown(e) {
if (e.key === ' ') {
e.preventDefault(); // prevent scrolling
e.stopPropagation(); // prevent parent nodes from toggling their checked state
if (!this.props.disabled) {
this.onCheck();
}
}
}

onExpand() {
const { expanded, value, onExpand } = this.props;

Expand Down Expand Up @@ -117,10 +140,13 @@ class TreeNode extends React.Component {

return (
<Button
// hide this button from the accessibility tree, as there is full keyboard control
aria-hidden
className="rct-collapse rct-collapse-btn"
disabled={expandDisabled}
title={lang.toggle}
onClick={this.onExpand}
tabIndex={-1}
>
{this.renderCollapseIcon()}
</Button>
Expand Down Expand Up @@ -178,21 +204,24 @@ class TreeNode extends React.Component {
const { onClick, title } = this.props;
const clickable = onClick !== null;

// Disable the lints about this control not being accessible
// We already provide full keyboard control, so this is clickable for mouse users
// eslint-disable-next-line max-len
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
return (
<span className="rct-bare-label" title={title}>
{clickable ? (
<span
className="rct-node-clickable"
onClick={this.onClick}
onKeyPress={this.onClick}
role="button"
tabIndex={0}
>
{children}
</span>
) : children}
</span>
);
// eslint-disable-next-line max-len
/* eslint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
}

renderCheckboxLabel(children) {
Expand Down Expand Up @@ -226,13 +255,13 @@ class TreeNode extends React.Component {

if (clickable) {
render.push((
// We can disable the lint here, since keyboard functionality is already provided
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
key={1}
className="rct-node-clickable"
onClick={this.onClick}
onKeyPress={this.onClick}
role="link"
tabIndex={0}
>
{children}
</span>
Expand Down Expand Up @@ -267,11 +296,18 @@ class TreeNode extends React.Component {
return null;
}

return this.props.children;
return this.props.isParent ? (
<div role="group">
{this.props.children}
</div>
) : (
this.props.children
);
}

render() {
const {
checked,
className,
disabled,
expanded,
Expand All @@ -285,9 +321,22 @@ class TreeNode extends React.Component {
'rct-node-collapsed': !isLeaf && !expanded,
'rct-disabled': disabled,
}, className);
let ariaChecked = checked === 1 ? 'true' : 'false';
if (checked === 2) {
ariaChecked = 'mixed';
}

return (
<li className={nodeClass}>
<li
aria-checked={ariaChecked}
aria-disabled={disabled}
aria-expanded={this.props.isParent ? expanded || false : null}
className={nodeClass}
onKeyDown={this.onKeyDown}
ref={this.nodeRef}
role="treeitem"
tabIndex={this.props.hasFocus ? 0 : -1}
>
<span className="rct-text">
{this.renderCollapseButton()}
{this.renderLabel()}
Expand Down
4 changes: 4 additions & 0 deletions src/scss/react-checkbox-tree.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ $rct-clickable-hover: rgba($rct-icon-color, .1) !default;
$rct-clickable-focus: rgba($rct-icon-color, .2) !default;

.react-checkbox-tree {
// 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
// would appreciate help to fix
/*
display: flex;
flex-direction: row-reverse;
*/
font-size: 16px;

> ol {
Expand Down
2 changes: 1 addition & 1 deletion test/CheckboxTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('<CheckboxTree />', () => {

assert.deepEqual(
wrapper.find(TreeNode).prop('children').props,
{ children: [null, null] },
{ children: [null, null], role: 'presentation' },
);
});
});
Expand Down

0 comments on commit b03f44b

Please sign in to comment.