From a4fb8797b255f351383b019624c46941fdb41176 Mon Sep 17 00:00:00 2001 From: ellinge Date: Wed, 1 May 2019 02:06:54 +0200 Subject: [PATCH] feat: Add keyboard navigation (#225) * feat: Test try a11y in test with axe * fix: Initial commit (non working) * fix: Working keyboard navigation * fix: Backspace only on empty search * fix: Adjust code to check codeclimate * fix: Refactored some parts to utils * fix: Refactored some parts to utils * fix: Code refactoring * fix: Add documentation * fix: Refactor * fix: Refactor * fix: Refactor * fix: Refactor * fix: Refactor * fix: Add to typings * fix: Close on Enter * fix: Refactor * fix: Added test * fix: Added some aria * fix: Add label option * fix: Render until node on pagedown * fix: More tests added and fix pagedown on large search result * fix: Modify output on violations * fix: Code smells * fix: Updated doc and center scrollIntoView, fix hasMore() * fix: Trigger on*-events properly, only open for chars and whitelist * fix: Skip som aria in this branch * fix: Add tests and fix snapshot * fix: Add tests and fix snapshot * fix: Enabled by default * fix: Avoid scroll of whole page, only dropdown * fix: Remember focus during prop updates * fix: Delete unintentional dist-file * fix: Do not select readOnly/disabled * fix: Switch to babel-plugin-transform-runtime instead * fix: Add label to trigger as well * fix: Highlight tag on focus, ad aria-labels * fix: Highlight tag on focus, ad aria-labels * fix: Update snapshots, match default * fix: Allow navigate to disabled/readonly * fix: Code review/smells fixes * fix: Code smell * fix: Code smell * fix: Move to a11y-folder, shared onKeyDown test method * fix: Build error and tabIndex * fix: Set new focus after delete * fix: Code climate * fix: Code climate * fix: Select on tab for simpleSelect * fix: Add more tests * fix: Add prop for setting remove aria-label * fix: Code climate * fix: Add typing for labelRemove * fix: Adjust timeout * style: Bring prettier manually to reduce conflicts * fix: Prettier and removed comment * fix: Added migration guide * fix: Bundle text props * fix: Typing errors * fix: Renamed prop * Revert "fix: Renamed prop" This reverts commit 4145f8c9a9bc12a0b681b56977d2555b8fe6ddf2. * Revert "fix: Typing errors" This reverts commit 6aa4bebc9358dbb6f6819d7623ee0db9e0be6065. * Revert "fix: Bundle text props" This reverts commit 4256ce2bfe73aea030ebd6561a9b2607a05783f3. * Revert "fix: Added migration guide" This reverts commit a4fc03382ab0da4278b54c65e476ced480052767. * fix: Validate radioSelect also * fix: Moved around methods * refactor: Separate out test utils from exported ones * refactor: Use ES6 getter instead of custom method for accessing tags --- .babelrc | 5 +- .codeclimate.yml | 11 +- README.md | 21 ++ __snapshots__/src/index.test.js.md | 310 +++++++++--------- __snapshots__/src/index.test.js.snap | Bin 3167 -> 3250 bytes __snapshots__/src/tag/index.test.js.md | 5 + __snapshots__/src/tag/index.test.js.snap | Bin 325 -> 411 bytes __snapshots__/src/tree-node/index.test.js.md | 1 + .../src/tree-node/index.test.js.snap | Bin 496 -> 503 bytes .../src/tree-node/node-label.test.js.md | 2 + .../src/tree-node/node-label.test.js.snap | Bin 494 -> 522 bytes package.json | 2 + postcss.config.js | 6 +- src/a11y/a11y.test.js | 62 ++++ src/a11y/a11y.utils.js | 27 ++ src/a11y/index.js | 8 + src/index.js | 94 +++++- src/index.keyboardNav.test.js | 163 +++++++++ src/index.test.js | 14 + src/input/index.js | 26 +- src/tag/index.css | 9 + src/tag/index.js | 37 ++- src/tree-manager/index.js | 76 +++-- src/tree-manager/keyboardNavigation.js | 190 +++++++++++ src/tree-manager/nodeVisitor.js | 40 +++ src/tree-manager/tests/index.test.js | 8 +- src/tree-node/index.css | 4 + src/tree-node/index.js | 6 +- src/tree-node/index.test.js | 19 ++ src/tree-node/node-label.js | 3 +- src/tree-node/toggle.js | 22 +- src/tree/index.js | 22 +- types/react-dropdown-tree-select.d.ts | 4 + yarn.lock | 143 +++++--- 34 files changed, 1074 insertions(+), 266 deletions(-) create mode 100644 src/a11y/a11y.test.js create mode 100644 src/a11y/a11y.utils.js create mode 100644 src/a11y/index.js create mode 100644 src/index.keyboardNav.test.js create mode 100644 src/tree-manager/keyboardNavigation.js create mode 100644 src/tree-manager/nodeVisitor.js diff --git a/.babelrc b/.babelrc index 2fed07ad..1773a85e 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,9 @@ { "presets": [["es2015", { "modules": false }], "stage-0", "react"], - "plugins": ["transform-class-properties"], + "plugins": [ + "transform-class-properties", + ["transform-runtime", { "polyfill": false, "regenerator": true }] + ], "env": { "test": { "presets": ["es2015", "stage-0", "react"], diff --git a/.codeclimate.yml b/.codeclimate.yml index 46a008a5..477883ad 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -41,9 +41,8 @@ plugins: javascript: mass_threshold : 50 exclude_patterns: - - 'docs/' - - 'snapshots/' - - '**/tests/*' - - '**/node_modules/' - - '**/*.test.js' - - 'postcss.config.js' +- "docs/" +- "snapshots/" +- "**/tests/*" +- "**/node_modules/" +- "**/*.test.js" diff --git a/README.md b/README.md index b89db1af..80391762 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ A lightweight and fast control to render a select component that can display hie - [showDropdownAlways](#showDropdownAlways) - [form states (disabled|readOnly)](#formstates) - [id](#id) + - [label](#label) + - [labelRemove](#labelRemove) - [Styling and Customization](#styling-and-customization) - [Using default styles](#default-styles) - [Customizing with Bootstrap, Material Design styles](#customizing-styles) @@ -65,6 +67,7 @@ A lightweight and fast control to render a select component that can display hie - [Search debouncing](#search-debouncing) - [Virtualized rendering](#virtualized-rendering) - [Reducing costly DOM manipulations](#reducing-costly-dom-manipulations) +- [Keyboard navigation](#keyboard-navigation) - [FAQ](#faq) - [Doing more with HOCs](/docs/HOC.md) - [Development](#development) @@ -363,6 +366,18 @@ Specific id for container. The container renders with a default id of `rdtsN` wh Use to ensure a own unique id when a simple counter is not sufficient, e.g in a partial server render (SSR) +### label + +Type: `string` + +Adds `aria-labelledby` to search input when input starts with `#`, adds `aria-label` to search input when label has value (not containing '#') + +### labelRemove + +Type: `string` + +The text to display for `aria-label` on tag delete buttons which is combined with `aria-labelledby` pointing to the node label. Defaults to `Remove` + ## Styling and Customization ### Default styles @@ -414,6 +429,12 @@ Once you import default styles, it is easy to add/override the provided styles t - [With Bootstrap](/docs/examples/bootstrap) - [With Material Design ](/docs/examples/material) +## Keyboard navigation + +Adds navigation with `arrow` keys, `page down/up` / `home/end` and toggle of selection with `enter`. `Arrow/page up/down` also toggles open of dropdown if closed. + +To close open dropdown `escape` or `tab` can be used and `backspace` can be used for deletion of tags on empty search input. + ## Performance ### Search optimizations diff --git a/__snapshots__/src/index.test.js.md b/__snapshots__/src/index.test.js.md index 599fb5a8..11705502 100644 --- a/__snapshots__/src/index.test.js.md +++ b/__snapshots__/src/index.test.js.md @@ -4,6 +4,157 @@ The actual snapshot is saved in `index.test.js.snap`. Generated by [AVA](https://ava.li). +## always shows dropdown + +> Snapshot 1 + +
+
+ + + +
+ { + _children: [ + 'rdts-0-0', + 'rdts-0-1', + ], + _depth: 0, + _id: 'rdts-0', + children: undefined, + label: 'item1', + value: 'value1', + }, + 'rdts-0-0' => { + _children: [ + 'rdts-0-0-0', + 'rdts-0-0-1', + ], + _depth: 1, + _id: 'rdts-0-0', + _parent: 'rdts-0', + children: undefined, + label: 'item1-1', + value: 'value1-1', + }, + 'rdts-0-0-0' => { + _depth: 2, + _id: 'rdts-0-0-0', + _parent: 'rdts-0-0', + label: 'item1-1-1', + value: 'value1-1-1', + }, + 'rdts-0-0-1' => { + _depth: 2, + _id: 'rdts-0-0-1', + _parent: 'rdts-0-0', + label: 'item1-1-2', + value: 'value1-1-2', + }, + 'rdts-0-1' => { + _depth: 1, + _id: 'rdts-0-1', + _parent: 'rdts-0', + label: 'item1-2', + value: 'value1-2', + }, + 'rdts-1' => { + _children: [ + 'rdts-1-0', + 'rdts-1-1', + ], + _depth: 0, + _id: 'rdts-1', + children: undefined, + label: 'item2', + value: 'value2', + }, + 'rdts-1-0' => { + _children: [ + 'rdts-1-0-0', + 'rdts-1-0-1', + 'rdts-1-0-2', + ], + _depth: 1, + _id: 'rdts-1-0', + _parent: 'rdts-1', + children: undefined, + label: 'item2-1', + value: 'value2-1', + }, + 'rdts-1-0-0' => { + _depth: 2, + _id: 'rdts-1-0-0', + _parent: 'rdts-1-0', + label: 'item2-1-1', + value: 'value2-1-1', + }, + 'rdts-1-0-1' => { + _depth: 2, + _id: 'rdts-1-0-1', + _parent: 'rdts-1-0', + label: 'item2-1-2', + value: 'value2-1-2', + }, + 'rdts-1-0-2' => { + _children: [ + 'rdts-1-0-2-0', + ], + _depth: 2, + _id: 'rdts-1-0-2', + _parent: 'rdts-1-0', + children: undefined, + label: 'item2-1-3', + value: 'value2-1-3', + }, + 'rdts-1-0-2-0' => { + _depth: 3, + _id: 'rdts-1-0-2-0', + _parent: 'rdts-1-0-2', + label: 'item2-1-3-1', + value: 'value2-1-3-1', + }, + 'rdts-1-1' => { + _depth: 1, + _id: 'rdts-1-1', + _parent: 'rdts-1', + label: 'item2-2', + value: 'value2-2', + }, + } + } + onAction={Function {}} + onCheckboxChange={Function {}} + onNodeToggle={Function {}} + pageSize={100} + searchModeOn={false} + /> +
+
+
+ ## doesn't toggle dropdown if it's disabled > Snapshot 1 @@ -17,7 +168,6 @@ Generated by [AVA](https://ava.li). > @@ -121,6 +272,7 @@ Generated by [AVA](https://ava.li). onBlur={Function {}} onFocus={Function {}} onInputChange={Function {}} + onKeyDown={Function {}} onTagRemove={Function {}} tags={[]} > @@ -131,10 +283,12 @@ Generated by [AVA](https://ava.li). className="tag-item" > @@ -234,6 +388,7 @@ Generated by [AVA](https://ava.li). onBlur={Function {}} onFocus={Function {}} onInputChange={Function {}} + onKeyDown={Function {}} onTagRemove={Function {}} tags={[]} > @@ -244,10 +399,12 @@ Generated by [AVA](https://ava.li). className="tag-item" > @@ -279,156 +436,7 @@ Generated by [AVA](https://ava.li). onBlur={Function {}} onFocus={Function {}} onInputChange={Function {}} - onTagRemove={Function {}} - tags={[]} - /> - -
- { - _children: [ - 'rdts-0-0', - 'rdts-0-1', - ], - _depth: 0, - _id: 'rdts-0', - children: undefined, - label: 'item1', - value: 'value1', - }, - 'rdts-0-0' => { - _children: [ - 'rdts-0-0-0', - 'rdts-0-0-1', - ], - _depth: 1, - _id: 'rdts-0-0', - _parent: 'rdts-0', - children: undefined, - label: 'item1-1', - value: 'value1-1', - }, - 'rdts-0-0-0' => { - _depth: 2, - _id: 'rdts-0-0-0', - _parent: 'rdts-0-0', - label: 'item1-1-1', - value: 'value1-1-1', - }, - 'rdts-0-0-1' => { - _depth: 2, - _id: 'rdts-0-0-1', - _parent: 'rdts-0-0', - label: 'item1-1-2', - value: 'value1-1-2', - }, - 'rdts-0-1' => { - _depth: 1, - _id: 'rdts-0-1', - _parent: 'rdts-0', - label: 'item1-2', - value: 'value1-2', - }, - 'rdts-1' => { - _children: [ - 'rdts-1-0', - 'rdts-1-1', - ], - _depth: 0, - _id: 'rdts-1', - children: undefined, - label: 'item2', - value: 'value2', - }, - 'rdts-1-0' => { - _children: [ - 'rdts-1-0-0', - 'rdts-1-0-1', - 'rdts-1-0-2', - ], - _depth: 1, - _id: 'rdts-1-0', - _parent: 'rdts-1', - children: undefined, - label: 'item2-1', - value: 'value2-1', - }, - 'rdts-1-0-0' => { - _depth: 2, - _id: 'rdts-1-0-0', - _parent: 'rdts-1-0', - label: 'item2-1-1', - value: 'value2-1-1', - }, - 'rdts-1-0-1' => { - _depth: 2, - _id: 'rdts-1-0-1', - _parent: 'rdts-1-0', - label: 'item2-1-2', - value: 'value2-1-2', - }, - 'rdts-1-0-2' => { - _children: [ - 'rdts-1-0-2-0', - ], - _depth: 2, - _id: 'rdts-1-0-2', - _parent: 'rdts-1-0', - children: undefined, - label: 'item2-1-3', - value: 'value2-1-3', - }, - 'rdts-1-0-2-0' => { - _depth: 3, - _id: 'rdts-1-0-2-0', - _parent: 'rdts-1-0-2', - label: 'item2-1-3-1', - value: 'value2-1-3-1', - }, - 'rdts-1-1' => { - _depth: 1, - _id: 'rdts-1-1', - _parent: 'rdts-1', - label: 'item2-2', - value: 'value2-2', - }, - } - } - onAction={Function {}} - onCheckboxChange={Function {}} - onNodeToggle={Function {}} - pageSize={100} - searchModeOn={false} - /> -
- - Snapshot 1 - -
-
- - diff --git a/__snapshots__/src/index.test.js.snap b/__snapshots__/src/index.test.js.snap index ec82ecf27c523ecec3af21a59216d7fac195c241..89b87fbb47234cedbfb7e199df2bd98a7b2c730a 100644 GIT binary patch literal 3250 zcmV;j3{CSvRzVT^45wW&{BmHJK+3ddTCL1oGOqZFD zChz^;|M&a7ec#P3C4|_>m*+QKZJF3};lCHnf41t%PwNLrMob-OC&WVP&?3WBGCr=K3-m_&x+1Ya zoaaJGCiTz4IOoIQ4zL`o29JYRz@7jEyj53^NnoYZ#WKDz=8XBuAKXG-Ju6 zSt4OMJ04CZ#6q_#3fYC&lxSaEO#h7{-6_OcySZ2wFXnut$Z-g<=|ZS4Ar9Ggl7)~u za0Uo~h$THtHXP}v#%0MFmxE!KqQFY9RxAi>+vIqho5xgUKKXo-?FeMEa)*7Qt#f)` zER>80F{VlJ#qakkOUOf^%bccPCNoI7$mtqI#U-&$!{-^`3UD2(zZ|T_a#$Cmz&&6c z*v6)pBvYbFQNo`o@ef#i4!l-E&bCq|M&^`|XKR)Yy)NwjChJvnGq?AB0s zBpQzMF{&n8l?) zOly+sN+^8R=PuS~9b#w*QGXaZ+>Gm9FHP>br&I+ogDe=WGvOGTlRv|`qJSUnAP z(AFcgtiOSkwTzJOmr>UBTGq2*bq%jt(Mnqk_Ozv#p0*h5X-hyIOXrps8%yQp#u;pGoaia8 zLA3M$*b4RoM+G5cz-t1-$1PiMiuG?X77gz~?2A%}l!ESI^R5?nabWD(y z4!gL2298GQcuYs>*rBU*xGh@ySG`H&TsR^~;}O#spLCJC0A*W28<+)F6|048K!NAL zrW0Qa*?zpW5C@xin3<^1LgwQ0&0qnzvsf+UMHJWs-UWvZX(6L=g+C4a5Ofxyg)AvT z3#rM>Q-in<<%yC{y)DN_82S8|3nTS5JbHp=Fdig|6Gm3Rd<}TWfH3j|Y<>-1Ff5F` zTv}mdq&=@NGEG)LZlQ&db#m4_3Sq?NAjAV|z$kDoXahHb#ULeH{Nb1iBY#2de**`> z2wIBJ6hV0hnz_7+C_V72sJz!pKfo?FNTIW4Uo*da1* z-3|7E1B~>067BmQeO;GM<>|2h3AhqmpGSKs)1Uj5#mE*qx4NXzD~Y5QohzNxl#4Zf zF8buALP9Lb$CA?Tjjl!&v%vsZ4c4cBwDDo8YHPmBoi)d84Ev+hbS6;^6i=+0Cq zRcRlCtJfJ|8kh?P!SCtITXlh|f?`#BP;4)-LpfV;s4KULMJ0#!*fwgbg>fkU97l8_$*p|YTWsjjL; z9O(Th_Aq!6>;PYbrYZ~^+*CzXS*Ka0H(OM1h#{J(G}$?2l6Yw>Ker<-Jx?$wReR1-ek9Qsu3!a?Z`g$%8pK*)#+-*1b9OU z-;+pBH5U~?VUAjm0$At=rQnldO*edyrGwt3ZGwlEPOs_(C3FW+@pAB zGF+Dl8ut0z87PU+T8aS}^LfJqC{EOsHr=-3H=VJu(Va0mwxTv(@dC4fwPW*)k9zRQ zJ3{@rD223GlDM6_>5WB);k@A#rP8Vvv^>{-S zRXsWAw;I%KHJC2QYPTB9(`q_)NqH8{{Vpk%>WWp)V@l(uG;T`crZjF!(9 zZc5{(G;T`crZjF!&{V7}E_L@(#<;|**21mJQFJwU5ImY`y`T=RXnSuFZJ(~AWQQXOt~1Jq(|^M& z{jtj=3n5bg2Nr-q@Dz9rd;-|aU!WTlo;Ci6#hGbXOkjNC@pdw+rw+HV+UoECtD_DN kv3b?uH&|VD_$xM#I^1Nh($ literal 3167 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 F0001HAJqT= diff --git a/__snapshots__/src/tag/index.test.js.md b/__snapshots__/src/tag/index.test.js.md index 5d707bc9..74e5bc2a 100644 --- a/__snapshots__/src/tag/index.test.js.md +++ b/__snapshots__/src/tag/index.test.js.md @@ -10,11 +10,16 @@ Generated by [AVA](https://ava.li). hello diff --git a/src/tree-manager/index.js b/src/tree-manager/index.js index 14f99a80..20416f1a 100644 --- a/src/tree-manager/index.js +++ b/src/tree-manager/index.js @@ -1,7 +1,8 @@ import getPartialState from './getPartialState' - import { isEmpty } from '../utils' import flattenTree from './flatten-tree' +import nodeVisitor from './nodeVisitor' +import keyboardNavigation, { FocusActionNames } from './keyboardNavigation' class TreeManager { constructor({ data, simpleSelect, radioSelect, showPartiallySelected, hierarchical, rootPrefixId }) { @@ -47,20 +48,17 @@ class TreeManager { const matches = [] + const addOnMatch = node => { + if (node.label.toLowerCase().indexOf(searchTerm) >= 0) { + matches.push(node._id) + } + } + if (closestMatch !== searchTerm) { const superMatches = this.searchMaps.get(closestMatch) - superMatches.forEach(key => { - const node = this.getNodeById(key) - if (node.label.toLowerCase().indexOf(searchTerm) >= 0) { - matches.push(node._id) - } - }) + superMatches.forEach(key => addOnMatch(this.getNodeById(key))) } else { - this.tree.forEach(node => { - if (node.label.toLowerCase().indexOf(searchTerm) >= 0) { - matches.push(node._id) - } - }) + this.tree.forEach(addOnMatch) } this.searchMaps.set(searchTerm, matches) @@ -235,43 +233,49 @@ class TreeManager { } } - getTags() { + get tags() { if (this.radioSelect || this.simpleSelect) { - return this._getTagsForSingleSelect() - } - - const tags = [] - const visited = {} - const markSubTreeVisited = node => { - visited[node._id] = true - if (!isEmpty(node._children)) node._children.forEach(c => markSubTreeVisited(this.getNodeById(c))) + if (this.currentChecked) { + return [this.getNodeById(this.currentChecked)] + } + return [] } - this.tree.forEach((node, key) => { - if (visited[key]) return - - if (node.checked) { - tags.push(node) - } else { - visited[key] = true - } + return nodeVisitor.getNodesMatching(this.tree, (node, key, visited) => { if (node.checked && !this.hierarchical) { // Parent node, so no need to walk children - markSubTreeVisited(node) + nodeVisitor.markSubTreeVisited(node, visited, id => this.getNodeById(id)) } + return node.checked }) - return tags } getTreeAndTags() { - return { tree: this.tree, tags: this.getTags() } + return { tree: this.tree, tags: this.tags } } - _getTagsForSingleSelect() { - if (this.currentChecked) { - return [this.getNodeById(this.currentChecked)] + handleNavigationKey(currentFocus, tree, key, readOnly, markSubTreeOnNonExpanded, onToggleChecked, onToggleExpanded) { + const prevFocus = currentFocus && this.getNodeById(currentFocus) + const getNodeById = id => this.getNodeById(id) + const action = keyboardNavigation.getAction(prevFocus, key) + + if (FocusActionNames.has(action)) { + const newFocus = keyboardNavigation.handleFocusNavigationkey( + tree, + action, + prevFocus, + getNodeById, + markSubTreeOnNonExpanded + ) + return newFocus + } + + if (!prevFocus || !tree.has(prevFocus._id)) { + // No current focus or not visible + return currentFocus } - return [] + + return keyboardNavigation.handleToggleNavigationkey(action, prevFocus, readOnly, onToggleChecked, onToggleExpanded) } } diff --git a/src/tree-manager/keyboardNavigation.js b/src/tree-manager/keyboardNavigation.js new file mode 100644 index 00000000..9798456e --- /dev/null +++ b/src/tree-manager/keyboardNavigation.js @@ -0,0 +1,190 @@ +import nodeVisitor from './nodeVisitor' + +const Keys = { + Up: 'ArrowUp', + Down: 'ArrowDown', + Left: 'ArrowLeft', + Right: 'ArrowRight', + Enter: 'Enter', + Home: 'Home', + PageUp: 'PageUp', + End: 'End', + PageDown: 'PageDown', +} + +export const NavActions = { + None: 'None', + FocusPrevious: 'FocusPrevious', + FocusNext: 'FocusNext', + FocusParent: 'FocusParent', + FocusFirst: 'FocusFirst', + FocusLast: 'FocusLast', + ToggleExpanded: 'ToggleExpanded', + ToggleChecked: 'ToggleChecked', +} + +export const FocusActionNames = new Set([ + NavActions.FocusPrevious, + NavActions.FocusNext, + NavActions.FocusParent, + NavActions.FocusFirst, + NavActions.FocusLast, +]) + +const validTriggerOpenKeys = [Keys.Up, Keys.Down, Keys.Home, Keys.PageUp, Keys.End, Keys.PageDown] +const validKeys = validTriggerOpenKeys.concat([Keys.Left, Keys.Right, Keys.Enter]) + +const isValidKey = (key, isOpen) => { + const keysToCheck = isOpen ? validKeys : validTriggerOpenKeys + return keysToCheck.indexOf(key) > -1 +} + +const isMatchingEvent = (key, keys, currentFocus, nonFocusKey) => + keys.indexOf(key) > -1 || (!currentFocus && key === nonFocusKey) + +const isFocusFirstEvent = (key, currentFocus) => isMatchingEvent(key, [Keys.Home, Keys.PageUp], currentFocus, Keys.Down) + +const isFocusLastEvent = (key, currentFocus) => isMatchingEvent(key, [Keys.End, Keys.PageDown], currentFocus, Keys.Up) + +const isReverseTraverseAction = action => + isMatchingEvent(action, [NavActions.FocusPrevious, NavActions.FocusLast], true) + +const isEdgeTraverseAction = action => isMatchingEvent(action, [NavActions.FocusFirst, NavActions.FocusLast], true) + +const getLeftNavAction = (currentFocus, key) => { + if (!currentFocus || key !== Keys.Left) return NavActions.None + + if (currentFocus.expanded === true) { + return NavActions.ToggleExpanded + } + if (currentFocus._parent) { + return NavActions.FocusParent + } + + return NavActions.None +} + +const getRightNavAction = (currentFocus, key) => { + if (!currentFocus || !currentFocus._children || key !== Keys.Right) { + return NavActions.None + } + + return currentFocus.expanded !== true ? NavActions.ToggleExpanded : NavActions.FocusNext +} + +const getRelativeAction = (currentFocus, key) => { + if (!currentFocus) return NavActions.None + switch (key) { + case Keys.Up: + return NavActions.FocusPrevious + case Keys.Down: + return NavActions.FocusNext + case Keys.Enter: + return NavActions.ToggleChecked + default: + return NavActions.None + } +} + +const getAction = (currentFocus, key) => { + let action + if (key === Keys.Left) { + action = getLeftNavAction(currentFocus, key) + } else if (key === Keys.Right) { + action = getRightNavAction(currentFocus, key) + } else if (isFocusFirstEvent(key, currentFocus)) { + action = NavActions.FocusFirst + } else if (isFocusLastEvent(key, currentFocus)) { + action = NavActions.FocusLast + } else { + action = getRelativeAction(currentFocus, key) + } + return action +} + +const getParentFocus = (prevFocus, getNodeById) => + prevFocus && prevFocus._parent ? getNodeById(prevFocus._parent) : prevFocus + +const getRelativeNeighborsFocus = (sortedNodes, prevFocus) => { + const nextIndex = sortedNodes.indexOf(prevFocus) + 1 + if (nextIndex % sortedNodes.length === 0) { + return sortedNodes[0] + } + return sortedNodes[nextIndex] +} + +const getRelativeFocus = (sortedNodes, prevFocus, action) => { + if (!sortedNodes || sortedNodes.length === 0) { + return prevFocus + } + + let focus = prevFocus + if (isEdgeTraverseAction(action)) { + ;[focus] = sortedNodes + } else if ([NavActions.FocusPrevious, NavActions.FocusNext].indexOf(action) > -1) { + focus = getRelativeNeighborsFocus(sortedNodes, prevFocus) + } + return focus +} + +const getNextFocus = (tree, prevFocus, action, getNodeById, markSubTreeOnNonExpanded) => { + if (action === NavActions.FocusParent) { + return getParentFocus(prevFocus, getNodeById) + } + if (!FocusActionNames.has(action)) { + return prevFocus + } + + let nodes = nodeVisitor.getVisibleNodes(tree, getNodeById, markSubTreeOnNonExpanded) + if (isReverseTraverseAction(action)) { + nodes = nodes.reverse() + } + + return getRelativeFocus(nodes, prevFocus, action) +} + +const getNextFocusAfterTagDelete = (deletedId, prevTags, tags, fallback) => { + // Sets new focus to next tag or returns fallback + let index = prevTags && prevTags.findIndex(t => t._id === deletedId) + if (index < 0 || !tags.length) return fallback + + index = tags.length > index ? index : tags.length - 1 + const newFocusId = tags[index]._id + const focusNode = document.getElementById(`${newFocusId}_tag`) + if (focusNode) { + return focusNode.firstElementChild || fallback + } + return fallback +} + +const handleFocusNavigationkey = (tree, action, prevFocus, getNodeById, markSubTreeOnNonExpanded) => { + const newFocus = keyboardNavigation.getNextFocus(tree, prevFocus, action, getNodeById, markSubTreeOnNonExpanded) + if (prevFocus && newFocus && prevFocus._id !== newFocus._id) { + prevFocus._focused = false + } + if (newFocus) { + newFocus._focused = true + return newFocus._id + } + return prevFocus && prevFocus._id +} + +const handleToggleNavigationkey = (action, prevFocus, readOnly, onToggleChecked, onToggleExpanded) => { + if (action === NavActions.ToggleChecked && !readOnly && !(prevFocus.readOnly || prevFocus.disabled)) { + onToggleChecked(prevFocus._id, prevFocus.checked !== true) + } else if (action === NavActions.ToggleExpanded) { + onToggleExpanded(prevFocus._id) + } + return prevFocus && prevFocus._id +} + +const keyboardNavigation = { + isValidKey, + getAction, + getNextFocus, + getNextFocusAfterTagDelete, + handleFocusNavigationkey, + handleToggleNavigationkey, +} + +export default keyboardNavigation diff --git a/src/tree-manager/nodeVisitor.js b/src/tree-manager/nodeVisitor.js new file mode 100644 index 00000000..7b36700c --- /dev/null +++ b/src/tree-manager/nodeVisitor.js @@ -0,0 +1,40 @@ +import { isEmpty } from '../utils' + +const markSubTreeVisited = (node, visited, getItemById) => { + visited[node._id] = true + if (!isEmpty(node._children)) { + node._children.forEach(c => markSubTreeVisited(getItemById(c), visited, getItemById)) + } +} + +const getNodesMatching = (tree, nodePredicate) => { + const nodes = [] + const visited = {} + + tree.forEach((node, key) => { + if (visited[key]) return + + if (nodePredicate(node, key, visited)) { + nodes.push(node) + } + + visited[key] = true + }) + + return nodes +} + +const getVisibleNodes = (tree, getItemById, markSubTreeOnNonExpanded) => + getNodesMatching(tree, (node, key, visited) => { + if (markSubTreeOnNonExpanded && node._children && node._children.length && node.expanded !== true) { + markSubTreeVisited(node, visited, getItemById) + } + return !node.hide + }) + +const nodeVisitor = { + getNodesMatching, + getVisibleNodes, + markSubTreeVisited, +} +export default nodeVisitor diff --git a/src/tree-manager/tests/index.test.js b/src/tree-manager/tests/index.test.js index 8be6a8d3..29acc514 100644 --- a/src/tree-manager/tests/index.test.js +++ b/src/tree-manager/tests/index.test.js @@ -96,7 +96,7 @@ test('should get tags based on children check state', t => { ], } const manager = new TreeManager({ data: tree }) - t.deepEqual(manager.getTags().map(t => t.label), ['l1c1']) + t.deepEqual(manager.tags.map(t => t.label), ['l1c1']) }) test('should get tags based on parent check state', t => { @@ -113,7 +113,7 @@ test('should get tags based on parent check state', t => { ], } const manager = new TreeManager({ data: tree }) - t.deepEqual(manager.getTags().map(t => t.label), ['l1']) + t.deepEqual(manager.tags.map(t => t.label), ['l1']) }) test('should get tags based on multiple parent check state', t => { @@ -142,7 +142,7 @@ test('should get tags based on multiple parent check state', t => { }, ] const manager = new TreeManager({ data: tree }) - t.deepEqual(manager.getTags().map(t => t.label), ['l1', 'l2']) + t.deepEqual(manager.tags.map(t => t.label), ['l1', 'l2']) }) test('should get tags based on multiple parent/child check state', t => { @@ -171,7 +171,7 @@ test('should get tags based on multiple parent/child check state', t => { }, ] const manager = new TreeManager({ data: tree }) - t.deepEqual(manager.getTags().map(t => t.label), ['l1', 'l2c2']) + t.deepEqual(manager.tags.map(t => t.label), ['l1', 'l2c2']) }) test('should toggle children when checked', t => { diff --git a/src/tree-node/index.css b/src/tree-node/index.css index 4f5c7bb8..5e23d5b8 100644 --- a/src/tree-node/index.css +++ b/src/tree-node/index.css @@ -21,6 +21,10 @@ } } } + + &.focused { + background-color: #f4f4f4; + } } .toggle { diff --git a/src/tree-node/index.js b/src/tree-node/index.js index 324fce7b..e76f807a 100644 --- a/src/tree-node/index.js +++ b/src/tree-node/index.js @@ -27,6 +27,7 @@ const getNodeCx = props => { showPartiallySelected, readOnly, checked, + _focused: focused, } = props return cx( @@ -41,6 +42,7 @@ const getNodeCx = props => { partial: showPartiallySelected && partial, readOnly, checked, + focused, }, className ) @@ -102,8 +104,10 @@ class TreeNode extends PureComponent { const liCx = getNodeCx(this.props) const style = keepTreeOnSearch || !searchModeOn ? { paddingLeft: `${(_depth || 0) * 20}px` } : {} + const liId = `${_id}_li` + return ( -
  • +
  • { label: 'item0-0-0', value: 'value0-0-0', className: 'cn0-0-0', + _children: [{ label: 'item0-0-1', value: 'value0-0-1' }, { label: 'item0-0-2', value: 'value0-0-2' }], } const onChange = spy() @@ -53,6 +54,24 @@ test('notifies node toggle changes', t => { t.true(onChange.calledWith('0-0-0')) }) +test('can toggle with enter and space', t => { + const node = { + _id: '0-0-0', + _parent: '0-0', + label: 'item0-0-0', + value: 'value0-0-0', + className: 'cn0-0-0', + _children: [{ label: 'item0-0-1', value: 'value0-0-1' }, { label: 'item0-0-2', value: 'value0-0-2' }], + } + + ;[{ key: 'Enter' }, { keyCode: 32 }].forEach(event => { + const onChange = spy() + const wrapper = mount() + wrapper.find('.toggle').simulate('keydown', event) + t.true(onChange.calledWith('0-0-0')) + }) +}) + test('remove gap during search', t => { const node = { _id: '0-0-0', diff --git a/src/tree-node/node-label.js b/src/tree-node/node-label.js index 89bac4be..26474962 100644 --- a/src/tree-node/node-label.js +++ b/src/tree-node/node-label.js @@ -17,7 +17,6 @@ class NodeLabel extends PureComponent { value: PropTypes.string.isRequired, checked: PropTypes.bool, partial: PropTypes.bool, - expanded: PropTypes.bool, disabled: PropTypes.bool, dataset: PropTypes.object, simpleSelect: PropTypes.bool, @@ -56,7 +55,7 @@ class NodeLabel extends PureComponent { nodeLabelProps.onClick = this.handleCheckboxChange } - const sharedProps = { id, value, checked, disabled, readOnly } + const sharedProps = { id, value, checked, disabled, readOnly, tabIndex: -1 } return (