Skip to content

Commit

Permalink
Make CheckboxTree view 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 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
  • Loading branch information
Neurrone committed Aug 4, 2019
1 parent ab0478c commit a584509
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 19 deletions.
129 changes: 119 additions & 10 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,86 @@ 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 in-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 && currentlyFocusedNode.isParent) {
if (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 in-order traversal
const parent = this.visibleNodes.slice(0, focusedNodeIndex)
.reverse()
// eslint-disable-next-line max-len
.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 => model.getNode(val).label.startsWith(e.key));
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 +304,17 @@ 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) => {
// 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;
}

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 @@ -223,13 +327,6 @@ class CheckboxTree extends React.Component {
// Show checkbox only if this is a leaf node or showCheckbox is true
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;
}

return (
<TreeNode
key={key}
Expand All @@ -239,6 +336,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 +359,7 @@ class CheckboxTree extends React.Component {
});

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

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

const className = classNames({
Expand All @@ -339,7 +440,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 a584509

Please sign in to comment.