From b03f44bf0d7abfbe74f5f0cbefce7899890aaa14 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 4 Aug 2019 20:14:58 +0800 Subject: [PATCH] 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 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 --- src/js/CheckboxTree.js | 128 ++++++++++++++++++++++++++++-- src/js/NativeCheckbox.js | 5 +- src/js/TreeNode.js | 63 +++++++++++++-- src/scss/react-checkbox-tree.scss | 4 + test/CheckboxTree.js | 2 +- 5 files changed, 188 insertions(+), 14 deletions(-) diff --git a/src/js/CheckboxTree.js b/src/js/CheckboxTree.js index 21f52144..6cf71e79 100644 --- a/src/js/CheckboxTree.js +++ b/src/js/CheckboxTree.js @@ -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, @@ -87,6 +101,7 @@ class CheckboxTree extends React.Component { }); this.state = { + focusedNodeIndex: null, id: props.id || `rct-${nanoid(7)}`, model, prevProps: props, @@ -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 @@ -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; @@ -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; @@ -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; } @@ -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} @@ -261,7 +368,7 @@ class CheckboxTree extends React.Component { }); return ( -
    +
      {treeNodes}
    ); @@ -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({ @@ -339,7 +449,15 @@ class CheckboxTree extends React.Component {
    {this.renderExpandAll()} {this.renderHiddenInput()} - {treeNodes} +
    + {treeNodes} +
    ); } diff --git a/src/js/NativeCheckbox.js b/src/js/NativeCheckbox.js index 3a78561c..f6563300 100644 --- a/src/js/NativeCheckbox.js +++ b/src/js/NativeCheckbox.js @@ -30,7 +30,10 @@ class NativeCheckbox extends React.PureComponent { // Remove property that does not exist in HTML delete props.indeterminate; - return { 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 { this.checkbox = c; }} type="checkbox" aria-hidden tabIndex={-1} />; } } diff --git a/src/js/TreeNode.js b/src/js/TreeNode.js index 71ba66d7..7d2e1fd8 100644 --- a/src/js/TreeNode.js +++ b/src/js/TreeNode.js @@ -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, @@ -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; @@ -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; @@ -117,10 +140,13 @@ class TreeNode extends React.Component { return ( @@ -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 ( {clickable ? ( {children} ) : children} ); + // eslint-disable-next-line max-len + /* eslint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ } renderCheckboxLabel(children) { @@ -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 {children} @@ -267,11 +296,18 @@ class TreeNode extends React.Component { return null; } - return this.props.children; + return this.props.isParent ? ( +
    + {this.props.children} +
    + ) : ( + this.props.children + ); } render() { const { + checked, className, disabled, expanded, @@ -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 ( -
  1. +
  2. {this.renderCollapseButton()} {this.renderLabel()} diff --git a/src/scss/react-checkbox-tree.scss b/src/scss/react-checkbox-tree.scss index 39ff93f0..2bfa6323 100644 --- a/src/scss/react-checkbox-tree.scss +++ b/src/scss/react-checkbox-tree.scss @@ -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 { diff --git a/test/CheckboxTree.js b/test/CheckboxTree.js index 17ba31a2..8f98cd6e 100644 --- a/test/CheckboxTree.js +++ b/test/CheckboxTree.js @@ -178,7 +178,7 @@ describe('', () => { assert.deepEqual( wrapper.find(TreeNode).prop('children').props, - { children: [null, null] }, + { children: [null, null], role: 'presentation' }, ); }); });