diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbabc11ab..e6067d9c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/react/src/components/Tree/Tree.tsx b/packages/react/src/components/Tree/Tree.tsx index 75cbac62da..5f175f6762 100644 --- a/packages/react/src/components/Tree/Tree.tsx +++ b/packages/react/src/components/Tree/Tree.tsx @@ -123,11 +123,16 @@ class Tree extends AutoControlledComponent, 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) @@ -136,7 +141,7 @@ class Tree extends AutoControlledComponent, 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) }, }) diff --git a/packages/react/src/components/Tree/TreeItem.tsx b/packages/react/src/components/Tree/TreeItem.tsx index b9cf92efa9..30f4d4badf 100644 --- a/packages/react/src/components/Tree/TreeItem.tsx +++ b/packages/react/src/components/Tree/TreeItem.tsx @@ -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, @@ -15,6 +16,7 @@ import { UIComponentProps, ChildrenComponentProps, rtlTextContainer, + applyAccessibilityKeyHandlers, } from '../../lib' import { ComponentEventHandler, @@ -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 { @@ -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 @@ -100,7 +103,33 @@ class TreeItem extends UIComponent> { static defaultProps = { as: 'li', - accessibility: defaultBehavior, + accessibility: treeItemBehavior, + } + + titleRef = React.createRef() + treeRef = React.createRef() + + 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) => ({ @@ -116,24 +145,29 @@ class TreeItem extends UIComponent> { return ( <> - {TreeTitle.create(title, { - defaultProps: { - className: TreeItem.slotClassNames.title, - open, - hasSubtree, - }, - render: renderItemTitle, - overrideProps: this.handleTitleOverrides, - })} - {open && - Tree.create(items, { + + {TreeTitle.create(title, { defaultProps: { - accessibility: subtreeBehavior, - className: TreeItem.slotClassNames.subtree, - exclusive, - renderItemTitle, + className: TreeItem.slotClassNames.title, + open, + hasSubtree, }, + render: renderItemTitle, + overrideProps: this.handleTitleOverrides, })} + + {hasSubtree && open && ( + + {Tree.create(items, { + defaultProps: { + accessibility: subtreeBehavior, + className: TreeItem.slotClassNames.subtree, + exclusive, + renderItemTitle, + }, + })} + + )} ) } @@ -147,6 +181,7 @@ class TreeItem extends UIComponent> { {...accessibility.attributes.root} {...rtlTextContainer.getAttributes({ forElements: [children] })} {...unhandledProps} + {...applyAccessibilityKeyHandlers(accessibility.keyHandlers.root, unhandledProps)} > {childrenExist(children) ? children : this.renderContent()} diff --git a/packages/react/src/components/Tree/TreeTitle.tsx b/packages/react/src/components/Tree/TreeTitle.tsx index b507130dd7..cbcd2b3efc 100644 --- a/packages/react/src/components/Tree/TreeTitle.tsx +++ b/packages/react/src/components/Tree/TreeTitle.tsx @@ -66,6 +66,22 @@ class TreeTitle extends UIComponent> { 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 => { diff --git a/packages/react/src/lib/accessibility/Behaviors/Tree/treeItemBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Tree/treeItemBehavior.ts new file mode 100644 index 0000000000..83f521ecfd --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Tree/treeItemBehavior.ts @@ -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 diff --git a/packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts index 1c6c18be70..5d108be045 100644 --- a/packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts +++ b/packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts @@ -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: { @@ -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 }], + }, }, }, }) diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index bd21b4fcf3..cb2e8d0267 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -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' diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index a97ba665fb..dd31ff0a14 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -34,6 +34,7 @@ import { menuItemAsToolbarButtonBehavior, treeBehavior, treeTitleBehavior, + treeItemBehavior, subtreeBehavior, gridBehavior, statusBehavior, @@ -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) diff --git a/packages/react/test/specs/components/Tree/Tree-test.tsx b/packages/react/test/specs/components/Tree/Tree-test.tsx index cf506b4933..3c8394e4d3 100644 --- a/packages/react/test/specs/components/Tree/Tree-test.tsx +++ b/packages/react/test/specs/components/Tree/Tree-test.tsx @@ -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 = [ { @@ -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() + + 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() + + 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() + + 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() - 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() + + 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() - 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() + + 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() + + wrapper + .find(`.${TreeTitle.className}`) + .at(0) // title 1 + .simulate('keydown', { keyCode: keyboardKey['*'] }) + checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3']) }) }) })