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

feat(Tree): left and right arrow keyboard navigation #1451

Merged
merged 25 commits into from
Jun 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ae9db9a
removed circular navigation property
silviuaavram Jun 4, 2019
3f0684a
implemented left-right kb navigation
silviuaavram Jun 4, 2019
d0f5cba
added tests to behaviors
silviuaavram Jun 5, 2019
b14600b
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 5, 2019
d1df8c2
fixed a bug caused by merge
silviuaavram Jun 5, 2019
5ea2690
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 6, 2019
a4ce9d6
changelog
silviuaavram Jun 6, 2019
db99a93
used refs directly in TreeItem
silviuaavram Jun 6, 2019
4efebd7
removed unneeded parameter
silviuaavram Jun 6, 2019
7caeb9a
refactored the open logic
silviuaavram Jun 6, 2019
d8e4d27
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 7, 2019
abbe377
Merge branch 'feat-tree-navigation-left-right' of https://github.com/…
silviuaavram Jun 7, 2019
8fe9c72
added unit tests
silviuaavram Jun 7, 2019
6d4524b
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 7, 2019
8bcfd47
implemented code review
silviuaavram Jun 7, 2019
9062b90
Merge branch 'feat-tree-navigation-left-right' of https://github.com/…
silviuaavram Jun 7, 2019
58eea3f
updated behavior in doc
silviuaavram Jun 7, 2019
0d0f630
fixed a destructuring issue
silviuaavram Jun 10, 2019
4da3d84
renamed the handlers
silviuaavram Jun 10, 2019
15a1a9d
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 10, 2019
02f47b0
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 10, 2019
9995d30
merged master
silviuaavram Jun 10, 2019
cdfaaf3
improved tests
silviuaavram Jun 10, 2019
dfb2624
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 10, 2019
7b54477
Merge branch 'master' into feat-tree-navigation-left-right
silviuaavram Jun 11, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Features
- Add `Toolbar` component @miroslavstastny ([#1408](https://github.com/stardust-ui/react/pull/1408))
- Add `disableAnimations` boolean prop on the `Provider` @mnajdova ([#1377](https://github.com/stardust-ui/react/pull/1377))
- Add expand/collapse and navigation with `ArrowUp` and `ArrowDown` to `Tree` @silviuavram ([#1457](https://github.com/stardust-ui/react/pull/1457))
- Expand all `Tree` siblings on `asterisk` key @silviuavram ([#1457](https://github.com/stardust-ui/react/pull/1457))
- Add 'data-is-focusable' attribute to `attachmentBehavior` @sophieH29 ([#1445](https://github.com/stardust-ui/react/pull/1445))
- Improve accessibility for `Checkbox` @jurokapsiar ([1479](https://github.com/stardust-ui/react/pull/1479))
Expand Down
11 changes: 8 additions & 3 deletions packages/react/src/components/Tree/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,16 @@ class Tree extends AutoControlledComponent<WithAsProp<TreeProps>, TreeState> {
return _.isArray(activeIndex) ? activeIndex : [activeIndex]
}

computeNewIndex = (index: number) => {
computeNewIndex = (treeItemProps: TreeItemProps) => {
const { index, items } = treeItemProps
const activeIndexes = this.getActiveIndexes()
const { exclusive } = this.props
if (!items) {
return activeIndexes
}

if (exclusive) return index
const activeIndexes = this.getActiveIndexes()

// check to see if index is in array, and remove it, if not then add it
return _.includes(activeIndexes, index)
? _.without(activeIndexes, index)
Expand All @@ -136,7 +141,7 @@ class Tree extends AutoControlledComponent<WithAsProp<TreeProps>, TreeState> {

handleTreeItemOverrides = (predefinedProps: TreeItemProps) => ({
onTitleClick: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
this.trySetState({ activeIndex: this.computeNewIndex(treeItemProps.index) })
this.trySetState({ activeIndex: this.computeNewIndex(treeItemProps) })
_.invoke(predefinedProps, 'onTitleClick', e, treeItemProps)
},
})
Expand Down
71 changes: 53 additions & 18 deletions packages/react/src/components/Tree/TreeItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import * as customPropTypes from '@stardust-ui/react-proptypes'
import * as _ from 'lodash'
import * as PropTypes from 'prop-types'
import * as React from 'react'
import { Ref } from '@stardust-ui/react-component-ref'

import Tree from './Tree'
import TreeTitle, { TreeTitleProps } from './TreeTitle'
import { defaultBehavior } from '../../lib/accessibility'
import { treeItemBehavior } from '../../lib/accessibility'
import { Accessibility } from '../../lib/accessibility/types'
import {
UIComponent,
Expand All @@ -15,6 +16,7 @@ import {
UIComponentProps,
ChildrenComponentProps,
rtlTextContainer,
applyAccessibilityKeyHandlers,
} from '../../lib'
import {
ComponentEventHandler,
Expand All @@ -23,6 +25,7 @@ import {
ShorthandValue,
withSafeTypeForAs,
} from '../../types'
import { getFirstFocusable } from '../../lib/accessibility/FocusZone/focusUtilities'
import subtreeBehavior from '../../lib/accessibility/Behaviors/Tree/subtreeBehavior'

export interface TreeItemSlotClassNames {
Expand All @@ -33,7 +36,7 @@ export interface TreeItemSlotClassNames {
export interface TreeItemProps extends UIComponentProps, ChildrenComponentProps {
/**
* Accessibility behavior if overridden by the user.
* @default defaultBehavior
* @default treeItemBehavior
*/
accessibility?: Accessibility

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

static defaultProps = {
as: 'li',
accessibility: defaultBehavior,
accessibility: treeItemBehavior,
}

titleRef = React.createRef<HTMLElement>()
treeRef = React.createRef<HTMLElement>()

actionHandlers = {
getFocusFromParent: e => {
const { open } = this.props
if (open) {
e.stopPropagation()
this.titleRef.current.focus()
}
},
setFocusToFirstChild: e => {
const { open } = this.props
if (!open) {
return
}

e.stopPropagation()

const element = getFirstFocusable(this.treeRef.current, this.treeRef.current, true)
if (element) {
element.focus()
}
},
}

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

return (
<>
{TreeTitle.create(title, {
defaultProps: {
className: TreeItem.slotClassNames.title,
open,
hasSubtree,
},
render: renderItemTitle,
overrideProps: this.handleTitleOverrides,
})}
{open &&
Tree.create(items, {
<Ref innerRef={this.titleRef}>
{TreeTitle.create(title, {
defaultProps: {
accessibility: subtreeBehavior,
className: TreeItem.slotClassNames.subtree,
exclusive,
renderItemTitle,
className: TreeItem.slotClassNames.title,
open,
hasSubtree,
},
render: renderItemTitle,
overrideProps: this.handleTitleOverrides,
})}
</Ref>
{hasSubtree && open && (
<Ref innerRef={this.treeRef}>
{Tree.create(items, {
defaultProps: {
accessibility: subtreeBehavior,
className: TreeItem.slotClassNames.subtree,
exclusive,
renderItemTitle,
},
})}
</Ref>
)}
</>
)
}
Expand All @@ -147,6 +181,7 @@ class TreeItem extends UIComponent<WithAsProp<TreeItemProps>> {
{...accessibility.attributes.root}
{...rtlTextContainer.getAttributes({ forElements: [children] })}
{...unhandledProps}
{...applyAccessibilityKeyHandlers(accessibility.keyHandlers.root, unhandledProps)}
>
{childrenExist(children) ? children : this.renderContent()}
</ElementType>
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/components/Tree/TreeTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ class TreeTitle extends UIComponent<WithAsProp<TreeTitleProps>> {
e.preventDefault()
this.handleClick(e)
},
expand: e => {
const { open } = this.props
e.preventDefault()
if (!open) {
e.stopPropagation()
this.handleClick(e)
}
},
collapse: e => {
const { open } = this.props
e.preventDefault()
if (open) {
e.stopPropagation()
this.handleClick(e)
}
},
}

handleClick = e => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Accessibility } from '../../types'
import * as keyboardKey from 'keyboard-key'

/**
* @specification
* Triggers 'getFocusFromParent' action with 'ArrowLeft' on 'root'.
* Triggers 'setFocusToFirstChild' action with 'ArrowRight' on 'root'.
*/
const treeItemBehavior: Accessibility = (props: any) => ({
attributes: {
root: {},
},
keyActions: {
root: {
getFocusFromParent: {
keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
},
setFocusToFirstChild: {
keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
},
},
},
})

export default treeItemBehavior
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { IS_FOCUSABLE_ATTRIBUTE } from '../../FocusZone/focusUtilities'
* @specification
* Adds attribute 'aria-expanded=true' based on the property 'open' if the component has 'hasSubtree' property.
* Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'.
* Triggers 'expand' action with 'ArrowRight' on 'root'.
* Triggers 'collapse' action with 'ArrowLeft' on 'root'.
*/
const treeTitleBehavior: Accessibility = (props: any) => ({
attributes: {
Expand All @@ -21,6 +23,12 @@ const treeTitleBehavior: Accessibility = (props: any) => ({
performClick: {
keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }],
},
expand: {
keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
},
collapse: {
keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
},
},
},
})
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/lib/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { default as chatBehavior } from './Behaviors/Chat/chatBehavior'
export { default as chatMessageBehavior } from './Behaviors/Chat/chatMessageBehavior'
export { default as gridBehavior } from './Behaviors/Grid/gridBehavior'
export { default as treeBehavior } from './Behaviors/Tree/treeBehavior'
export { default as treeItemBehavior } from './Behaviors/Tree/treeItemBehavior'
export { default as treeTitleBehavior } from './Behaviors/Tree/treeTitleBehavior'
export { default as subtreeBehavior } from './Behaviors/Tree/subtreeBehavior'
export { default as dialogBehavior } from './Behaviors/Dialog/dialogBehavior'
Expand Down
2 changes: 2 additions & 0 deletions packages/react/test/specs/behaviors/behavior-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
menuItemAsToolbarButtonBehavior,
treeBehavior,
treeTitleBehavior,
treeItemBehavior,
subtreeBehavior,
gridBehavior,
statusBehavior,
Expand Down Expand Up @@ -80,6 +81,7 @@ testHelper.addBehavior('toggleButtonBehavior', toggleButtonBehavior)
testHelper.addBehavior('menuItemAsToolbarButtonBehavior', menuItemAsToolbarButtonBehavior)
testHelper.addBehavior('treeTitleBehavior', treeTitleBehavior)
testHelper.addBehavior('treeBehavior', treeBehavior)
testHelper.addBehavior('treeItemBehavior', treeItemBehavior)
testHelper.addBehavior('subtreeBehavior', subtreeBehavior)
testHelper.addBehavior('gridBehavior', gridBehavior)
testHelper.addBehavior('dialogBehavior', dialogBehavior)
Expand Down
104 changes: 94 additions & 10 deletions packages/react/test/specs/components/Tree/Tree-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isConformant } from 'test/specs/commonTests'
import { mountWithProvider } from 'test/utils'
import Tree from 'src/components/Tree/Tree'
import TreeTitle from 'src/components/Tree/TreeTitle'
import { ReactWrapper } from 'enzyme'

const items = [
{
Expand Down Expand Up @@ -53,26 +54,109 @@ const items = [
},
]

const checkOpenTitles = (wrapper: ReactWrapper, expected: string[]): void => {
const titles = wrapper.find(`.${TreeTitle.className}`)
expect(titles.length).toEqual(expected.length)

expected.forEach((expectedTitle, index) => {
expect(titles.at(index).getDOMNode().textContent).toEqual(expectedTitle)
})
}

describe('Tree', () => {
isConformant(Tree)

describe('activeIndex', () => {
it('should have all TreeItems with a subtree open on asterisk key', () => {
it('should contain index of item open at click', () => {
const wrapper = mountWithProvider(<Tree items={items} />)

wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('click')
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])

wrapper
.find(`.${TreeTitle.className}`)
.at(3) // title 2
.simulate('click')
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
})

it('should have index of item removed when closed at click', () => {
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1]} />)

wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('click')
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
})

it('should contain only one index at a time if exclusive', () => {
const wrapper = mountWithProvider(<Tree items={items} exclusive />)

wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('click')
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])

wrapper
.find(`.${TreeTitle.className}`)
.at(3) // title 2
.simulate('click')
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
})

it('should contain index of item open by ArrowRight', () => {
const wrapper = mountWithProvider(<Tree items={items} />)
const tree = wrapper.find(Tree).at(0)
const firstTreeTitle = wrapper.find(`.${TreeTitle.className}`).at(0)

firstTreeTitle.simulate('keydown', { keyCode: keyboardKey['*'] })
expect(tree.state('activeIndex')).toEqual([0, 1])
wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('keydown', { keyCode: keyboardKey.ArrowRight })
checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
})

it('should expand subtrees only on current level', () => {
it('should have index of item removed if closed by ArrowLeft', () => {
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1]} />)

wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('keydown', { keyCode: keyboardKey.ArrowLeft })
checkOpenTitles(wrapper, ['1', '2', '21', '22', '3'])
})

it('should have all TreeItems with a subtree open on asterisk key', () => {
const wrapper = mountWithProvider(<Tree items={items} />)
const firstTreeTitle = wrapper.find(`.${TreeTitle.className}`).at(0)

firstTreeTitle.simulate('keydown', { keyCode: keyboardKey['*'] })
const tree = wrapper.find(Tree)
expect(tree.length).toBe(3) // Parent tree + its 2 subtrees (1, 2). Did not expand the other 2 subtrees (12, 21).
wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('keydown', { keyCode: keyboardKey['*'] })
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
})

it('should expand subtrees only on current level on asterisk key', () => {
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0]} />)

wrapper
.find(`.${TreeTitle.className}`)
.at(1) // title 11
.simulate('keydown', { keyCode: keyboardKey['*'] })
checkOpenTitles(wrapper, ['1', '11', '12', '121', '2', '3'])
})

it('should not be changed on asterisk key if all siblings are already expanded', () => {
const wrapper = mountWithProvider(<Tree items={items} defaultActiveIndex={[0, 1, 2]} />)

wrapper
.find(`.${TreeTitle.className}`)
.at(0) // title 1
.simulate('keydown', { keyCode: keyboardKey['*'] })
checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
})
})
})