diff --git a/src/js/CheckboxTree.js b/src/js/CheckboxTree.js index 21f52144..32bd8282 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,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; @@ -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; @@ -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 ( +
    {treeNodes}
); @@ -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({ @@ -339,7 +440,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 ( -
  • +
  • {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' }, ); }); });