Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.

Commit fae74b5

Browse files
authored
feat(Tree): left and right arrow keyboard navigation (#1451)
* removed circular navigation property * implemented left-right kb navigation * added tests to behaviors * fixed a bug caused by merge * changelog * used refs directly in TreeItem * removed unneeded parameter * refactored the open logic * added unit tests * implemented code review * updated behavior in doc * fixed a destructuring issue * renamed the handlers * improved tests
1 parent e064a02 commit fae74b5

File tree

9 files changed

+208
-31
lines changed

9 files changed

+208
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
2525
### Features
2626
- Add `Toolbar` component @miroslavstastny ([#1408](https://github.com/stardust-ui/react/pull/1408))
2727
- Add `disableAnimations` boolean prop on the `Provider` @mnajdova ([#1377](https://github.com/stardust-ui/react/pull/1377))
28+
- Add expand/collapse and navigation with `ArrowUp` and `ArrowDown` to `Tree` @silviuavram ([#1457](https://github.com/stardust-ui/react/pull/1457))
2829
- Expand all `Tree` siblings on `asterisk` key @silviuavram ([#1457](https://github.com/stardust-ui/react/pull/1457))
2930
- Add 'data-is-focusable' attribute to `attachmentBehavior` @sophieH29 ([#1445](https://github.com/stardust-ui/react/pull/1445))
3031
- Improve accessibility for `Checkbox` @jurokapsiar ([1479](https://github.com/stardust-ui/react/pull/1479))

packages/react/src/components/Tree/Tree.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,16 @@ class Tree extends AutoControlledComponent<WithAsProp<TreeProps>, TreeState> {
123123
return _.isArray(activeIndex) ? activeIndex : [activeIndex]
124124
}
125125

126-
computeNewIndex = (index: number) => {
126+
computeNewIndex = (treeItemProps: TreeItemProps) => {
127+
const { index, items } = treeItemProps
128+
const activeIndexes = this.getActiveIndexes()
127129
const { exclusive } = this.props
130+
if (!items) {
131+
return activeIndexes
132+
}
128133

129134
if (exclusive) return index
130-
const activeIndexes = this.getActiveIndexes()
135+
131136
// check to see if index is in array, and remove it, if not then add it
132137
return _.includes(activeIndexes, index)
133138
? _.without(activeIndexes, index)
@@ -136,7 +141,7 @@ class Tree extends AutoControlledComponent<WithAsProp<TreeProps>, TreeState> {
136141

137142
handleTreeItemOverrides = (predefinedProps: TreeItemProps) => ({
138143
onTitleClick: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
139-
this.trySetState({ activeIndex: this.computeNewIndex(treeItemProps.index) })
144+
this.trySetState({ activeIndex: this.computeNewIndex(treeItemProps) })
140145
_.invoke(predefinedProps, 'onTitleClick', e, treeItemProps)
141146
},
142147
})

packages/react/src/components/Tree/TreeItem.tsx

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as customPropTypes from '@stardust-ui/react-proptypes'
22
import * as _ from 'lodash'
33
import * as PropTypes from 'prop-types'
44
import * as React from 'react'
5+
import { Ref } from '@stardust-ui/react-component-ref'
56

67
import Tree from './Tree'
78
import TreeTitle, { TreeTitleProps } from './TreeTitle'
8-
import { defaultBehavior } from '../../lib/accessibility'
9+
import { treeItemBehavior } from '../../lib/accessibility'
910
import { Accessibility } from '../../lib/accessibility/types'
1011
import {
1112
UIComponent,
@@ -15,6 +16,7 @@ import {
1516
UIComponentProps,
1617
ChildrenComponentProps,
1718
rtlTextContainer,
19+
applyAccessibilityKeyHandlers,
1820
} from '../../lib'
1921
import {
2022
ComponentEventHandler,
@@ -23,6 +25,7 @@ import {
2325
ShorthandValue,
2426
withSafeTypeForAs,
2527
} from '../../types'
28+
import { getFirstFocusable } from '../../lib/accessibility/FocusZone/focusUtilities'
2629
import subtreeBehavior from '../../lib/accessibility/Behaviors/Tree/subtreeBehavior'
2730

2831
export interface TreeItemSlotClassNames {
@@ -33,7 +36,7 @@ export interface TreeItemSlotClassNames {
3336
export interface TreeItemProps extends UIComponentProps, ChildrenComponentProps {
3437
/**
3538
* Accessibility behavior if overridden by the user.
36-
* @default defaultBehavior
39+
* @default treeItemBehavior
3740
*/
3841
accessibility?: Accessibility
3942

@@ -100,7 +103,33 @@ class TreeItem extends UIComponent<WithAsProp<TreeItemProps>> {
100103

101104
static defaultProps = {
102105
as: 'li',
103-
accessibility: defaultBehavior,
106+
accessibility: treeItemBehavior,
107+
}
108+
109+
titleRef = React.createRef<HTMLElement>()
110+
treeRef = React.createRef<HTMLElement>()
111+
112+
actionHandlers = {
113+
getFocusFromParent: e => {
114+
const { open } = this.props
115+
if (open) {
116+
e.stopPropagation()
117+
this.titleRef.current.focus()
118+
}
119+
},
120+
setFocusToFirstChild: e => {
121+
const { open } = this.props
122+
if (!open) {
123+
return
124+
}
125+
126+
e.stopPropagation()
127+
128+
const element = getFirstFocusable(this.treeRef.current, this.treeRef.current, true)
129+
if (element) {
130+
element.focus()
131+
}
132+
},
104133
}
105134

106135
handleTitleOverrides = (predefinedProps: TreeTitleProps) => ({
@@ -116,24 +145,29 @@ class TreeItem extends UIComponent<WithAsProp<TreeItemProps>> {
116145

117146
return (
118147
<>
119-
{TreeTitle.create(title, {
120-
defaultProps: {
121-
className: TreeItem.slotClassNames.title,
122-
open,
123-
hasSubtree,
124-
},
125-
render: renderItemTitle,
126-
overrideProps: this.handleTitleOverrides,
127-
})}
128-
{open &&
129-
Tree.create(items, {
148+
<Ref innerRef={this.titleRef}>
149+
{TreeTitle.create(title, {
130150
defaultProps: {
131-
accessibility: subtreeBehavior,
132-
className: TreeItem.slotClassNames.subtree,
133-
exclusive,
134-
renderItemTitle,
151+
className: TreeItem.slotClassNames.title,
152+
open,
153+
hasSubtree,
135154
},
155+
render: renderItemTitle,
156+
overrideProps: this.handleTitleOverrides,
136157
})}
158+
</Ref>
159+
{hasSubtree && open && (
160+
<Ref innerRef={this.treeRef}>
161+
{Tree.create(items, {
162+
defaultProps: {
163+
accessibility: subtreeBehavior,
164+
className: TreeItem.slotClassNames.subtree,
165+
exclusive,
166+
renderItemTitle,
167+
},
168+
})}
169+
</Ref>
170+
)}
137171
</>
138172
)
139173
}
@@ -147,6 +181,7 @@ class TreeItem extends UIComponent<WithAsProp<TreeItemProps>> {
147181
{...accessibility.attributes.root}
148182
{...rtlTextContainer.getAttributes({ forElements: [children] })}
149183
{...unhandledProps}
184+
{...applyAccessibilityKeyHandlers(accessibility.keyHandlers.root, unhandledProps)}
150185
>
151186
{childrenExist(children) ? children : this.renderContent()}
152187
</ElementType>

packages/react/src/components/Tree/TreeTitle.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ class TreeTitle extends UIComponent<WithAsProp<TreeTitleProps>> {
6666
e.preventDefault()
6767
this.handleClick(e)
6868
},
69+
expand: e => {
70+
const { open } = this.props
71+
e.preventDefault()
72+
if (!open) {
73+
e.stopPropagation()
74+
this.handleClick(e)
75+
}
76+
},
77+
collapse: e => {
78+
const { open } = this.props
79+
e.preventDefault()
80+
if (open) {
81+
e.stopPropagation()
82+
this.handleClick(e)
83+
}
84+
},
6985
}
7086

7187
handleClick = e => {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Accessibility } from '../../types'
2+
import * as keyboardKey from 'keyboard-key'
3+
4+
/**
5+
* @specification
6+
* Triggers 'getFocusFromParent' action with 'ArrowLeft' on 'root'.
7+
* Triggers 'setFocusToFirstChild' action with 'ArrowRight' on 'root'.
8+
*/
9+
const treeItemBehavior: Accessibility = (props: any) => ({
10+
attributes: {
11+
root: {},
12+
},
13+
keyActions: {
14+
root: {
15+
getFocusFromParent: {
16+
keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
17+
},
18+
setFocusToFirstChild: {
19+
keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
20+
},
21+
},
22+
},
23+
})
24+
25+
export default treeItemBehavior

packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { IS_FOCUSABLE_ATTRIBUTE } from '../../FocusZone/focusUtilities'
66
* @specification
77
* Adds attribute 'aria-expanded=true' based on the property 'open' if the component has 'hasSubtree' property.
88
* Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'.
9+
* Triggers 'expand' action with 'ArrowRight' on 'root'.
10+
* Triggers 'collapse' action with 'ArrowLeft' on 'root'.
911
*/
1012
const treeTitleBehavior: Accessibility = (props: any) => ({
1113
attributes: {
@@ -21,6 +23,12 @@ const treeTitleBehavior: Accessibility = (props: any) => ({
2123
performClick: {
2224
keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }],
2325
},
26+
expand: {
27+
keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
28+
},
29+
collapse: {
30+
keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
31+
},
2432
},
2533
},
2634
})

packages/react/src/lib/accessibility/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export { default as chatBehavior } from './Behaviors/Chat/chatBehavior'
3333
export { default as chatMessageBehavior } from './Behaviors/Chat/chatMessageBehavior'
3434
export { default as gridBehavior } from './Behaviors/Grid/gridBehavior'
3535
export { default as treeBehavior } from './Behaviors/Tree/treeBehavior'
36+
export { default as treeItemBehavior } from './Behaviors/Tree/treeItemBehavior'
3637
export { default as treeTitleBehavior } from './Behaviors/Tree/treeTitleBehavior'
3738
export { default as subtreeBehavior } from './Behaviors/Tree/subtreeBehavior'
3839
export { default as dialogBehavior } from './Behaviors/Dialog/dialogBehavior'

packages/react/test/specs/behaviors/behavior-test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
menuItemAsToolbarButtonBehavior,
3535
treeBehavior,
3636
treeTitleBehavior,
37+
treeItemBehavior,
3738
subtreeBehavior,
3839
gridBehavior,
3940
statusBehavior,
@@ -80,6 +81,7 @@ testHelper.addBehavior('toggleButtonBehavior', toggleButtonBehavior)
8081
testHelper.addBehavior('menuItemAsToolbarButtonBehavior', menuItemAsToolbarButtonBehavior)
8182
testHelper.addBehavior('treeTitleBehavior', treeTitleBehavior)
8283
testHelper.addBehavior('treeBehavior', treeBehavior)
84+
testHelper.addBehavior('treeItemBehavior', treeItemBehavior)
8385
testHelper.addBehavior('subtreeBehavior', subtreeBehavior)
8486
testHelper.addBehavior('gridBehavior', gridBehavior)
8587
testHelper.addBehavior('dialogBehavior', dialogBehavior)

packages/react/test/specs/components/Tree/Tree-test.tsx

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isConformant } from 'test/specs/commonTests'
55
import { mountWithProvider } from 'test/utils'
66
import Tree from 'src/components/Tree/Tree'
77
import TreeTitle from 'src/components/Tree/TreeTitle'
8+
import { ReactWrapper } from 'enzyme'
89

910
const items = [
1011
{
@@ -53,26 +54,109 @@ const items = [
5354
},
5455
]
5556

57+
const checkOpenTitles = (wrapper: ReactWrapper, expected: string[]): void => {
58+
const titles = wrapper.find(`.${TreeTitle.className}`)
59+
expect(titles.length).toEqual(expected.length)
60+
61+
expected.forEach((expectedTitle, index) => {
62+
expect(titles.at(index).getDOMNode().textContent).toEqual(expectedTitle)
63+
})
64+
}
65+
5666
describe('Tree', () => {
5767
isConformant(Tree)
5868

5969
describe('activeIndex', () => {
60-
it('should have all TreeItems with a subtree open on asterisk key', () => {
70+
it('should contain index of item open at click', () => {
71+
const wrapper = mountWithProvider(<Tree items={items} />)
72+
73+
wrapper
74+
.find(`.${TreeTitle.className}`)
75+
.at(0) // title 1
76+
.simulate('click')
77+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
78+
79+
wrapper
80+
.find(`.${TreeTitle.className}`)
81+
.at(3) // title 2
82+
.simulate('click')
83+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
84+
})
85+
86+
it('should have index of item removed when closed at click', () => {
87+
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1]} />)
88+
89+
wrapper
90+
.find(`.${TreeTitle.className}`)
91+
.at(0) // title 1
92+
.simulate('click')
93+
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
94+
})
95+
96+
it('should contain only one index at a time if exclusive', () => {
97+
const wrapper = mountWithProvider(<Tree items={items} exclusive />)
98+
99+
wrapper
100+
.find(`.${TreeTitle.className}`)
101+
.at(0) // title 1
102+
.simulate('click')
103+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
104+
105+
wrapper
106+
.find(`.${TreeTitle.className}`)
107+
.at(3) // title 2
108+
.simulate('click')
109+
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
110+
})
111+
112+
it('should contain index of item open by ArrowRight', () => {
61113
const wrapper = mountWithProvider(<Tree items={items} />)
62-
const tree = wrapper.find(Tree).at(0)
63-
const firstTreeTitle = wrapper.find(`.${TreeTitle.className}`).at(0)
64114

65-
firstTreeTitle.simulate('keydown', { keyCode: keyboardKey['*'] })
66-
expect(tree.state('activeIndex')).toEqual([0, 1])
115+
wrapper
116+
.find(`.${TreeTitle.className}`)
117+
.at(0) // title 1
118+
.simulate('keydown', { keyCode: keyboardKey.ArrowRight })
119+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
67120
})
68121

69-
it('should expand subtrees only on current level', () => {
122+
it('should have index of item removed if closed by ArrowLeft', () => {
123+
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1]} />)
124+
125+
wrapper
126+
.find(`.${TreeTitle.className}`)
127+
.at(0) // title 1
128+
.simulate('keydown', { keyCode: keyboardKey.ArrowLeft })
129+
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
130+
})
131+
132+
it('should have all TreeItems with a subtree open on asterisk key', () => {
70133
const wrapper = mountWithProvider(<Tree items={items} />)
71-
const firstTreeTitle = wrapper.find(`.${TreeTitle.className}`).at(0)
72134

73-
firstTreeTitle.simulate('keydown', { keyCode: keyboardKey['*'] })
74-
const tree = wrapper.find(Tree)
75-
expect(tree.length).toBe(3) // Parent tree + its 2 subtrees (1, 2). Did not expand the other 2 subtrees (12, 21).
135+
wrapper
136+
.find(`.${TreeTitle.className}`)
137+
.at(0) // title 1
138+
.simulate('keydown', { keyCode: keyboardKey['*'] })
139+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
140+
})
141+
142+
it('should expand subtrees only on current level on asterisk key', () => {
143+
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0]} />)
144+
145+
wrapper
146+
.find(`.${TreeTitle.className}`)
147+
.at(1) // title 11
148+
.simulate('keydown', { keyCode: keyboardKey['*'] })
149+
checkOpenTitles(wrapper, ['1', '11', '12', '121', '2', '3'])
150+
})
151+
152+
it('should not be changed on asterisk key if all siblings are already expanded', () => {
153+
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1, 2]} />)
154+
155+
wrapper
156+
.find(`.${TreeTitle.className}`)
157+
.at(0) // title 1
158+
.simulate('keydown', { keyCode: keyboardKey['*'] })
159+
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
76160
})
77161
})
78162
})

0 commit comments

Comments
 (0)