diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1f657e6e3..3ef72e4953 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Features
- Add `yellow`, `green`, `orange`, `pink`, `amethyst`, `silver` and `onyx` color schemes in Teams theme @mnajdova ([#1826](https://github.com/stardust-ui/react/pull/1826))
+- Add `Tree` component that is flat DOM structured @silviuavram ([#1779](https://github.com/stardust-ui/react/pull/1779))
### Fixes
- Fix `muted` prop in `Video` component @layershifter ([#1847](https://github.com/stardust-ui/react/pull/1847))
diff --git a/docs/src/examples/components/Tree/Types/TreeExample.shorthand.tsx b/docs/src/examples/components/Tree/Types/TreeExample.shorthand.tsx
new file mode 100644
index 0000000000..f226402d91
--- /dev/null
+++ b/docs/src/examples/components/Tree/Types/TreeExample.shorthand.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react'
+import { Tree } from '@stardust-ui/react'
+
+const items = [
+ {
+ id: '1',
+ title: 'House Lannister',
+ items: [
+ {
+ id: '11',
+ title: 'Tywin',
+ items: [
+ {
+ id: '111',
+ title: 'Jaime',
+ },
+ {
+ id: '112',
+ title: 'Cersei',
+ },
+ {
+ id: '113',
+ title: 'Tyrion',
+ },
+ ],
+ },
+ {
+ id: '12',
+ title: 'Kevan',
+ items: [
+ {
+ id: '121',
+ title: 'Lancel',
+ },
+ {
+ id: '122',
+ title: 'Willem',
+ },
+ {
+ id: '123',
+ title: 'Martyn',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: '2',
+ title: 'House Targaryen',
+ items: [
+ {
+ id: '21',
+ title: 'Aerys',
+ items: [
+ {
+ id: '211',
+ title: 'Rhaegar',
+ },
+ {
+ id: '212',
+ title: 'Viserys',
+ },
+ {
+ id: '213',
+ title: 'Daenerys',
+ },
+ ],
+ },
+ ],
+ },
+]
+
+const TreeExampleShorthand = () =>
+
+export default TreeExampleShorthand
diff --git a/docs/src/examples/components/Tree/Types/TreeExclusiveExample.shorthand.tsx b/docs/src/examples/components/Tree/Types/TreeExclusiveExample.shorthand.tsx
new file mode 100644
index 0000000000..d06f883c28
--- /dev/null
+++ b/docs/src/examples/components/Tree/Types/TreeExclusiveExample.shorthand.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react'
+import { Icon, Tree } from '@stardust-ui/react'
+
+const items = [
+ {
+ id: '1',
+ title: 'one',
+ items: [
+ {
+ id: '2',
+ title: 'one one',
+ items: [
+ {
+ id: '3',
+ title: 'one one one',
+ },
+ ],
+ },
+ {
+ id: '6',
+ title: 'one two',
+ items: [
+ {
+ id: '7',
+ title: 'one two one',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: '4',
+ title: 'two',
+ items: [
+ {
+ id: '5',
+ title: 'two one',
+ },
+ ],
+ },
+]
+
+const titleRenderer = (Component, { content, open, hasSubtree, ...restProps }) => (
+
+ {hasSubtree && }
+ {content}
+
+)
+
+const TreeExclusiveExample = () =>
+
+export default TreeExclusiveExample
diff --git a/docs/src/examples/components/Tree/Types/TreeInitiallyOpenExample.shorthand.tsx b/docs/src/examples/components/Tree/Types/TreeInitiallyOpenExample.shorthand.tsx
new file mode 100644
index 0000000000..008855f042
--- /dev/null
+++ b/docs/src/examples/components/Tree/Types/TreeInitiallyOpenExample.shorthand.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react'
+import { Tree } from '@stardust-ui/react'
+
+const items = [
+ {
+ id: '1',
+ title: 'House Lannister',
+ items: [
+ {
+ id: '11',
+ title: 'Tywin',
+ items: [
+ {
+ id: '111',
+ title: 'Jaime',
+ },
+ {
+ id: '112',
+ title: 'Cersei',
+ },
+ {
+ id: '113',
+ title: 'Tyrion',
+ },
+ ],
+ },
+ {
+ id: '12',
+ title: 'Kevan',
+ items: [
+ {
+ id: '121',
+ title: 'Lancel',
+ },
+ {
+ id: '122',
+ title: 'Willem',
+ },
+ {
+ id: '123',
+ title: 'Martyn',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: '2',
+ title: 'House Targaryen',
+ items: [
+ {
+ id: '21',
+ title: 'Aerys',
+ items: [
+ {
+ id: '211',
+ title: 'Rhaegar',
+ },
+ {
+ id: '212',
+ title: 'Viserys',
+ },
+ {
+ id: '213',
+ title: 'Daenerys',
+ },
+ ],
+ },
+ ],
+ },
+]
+
+const TreeInitiallyOpenExampleShorthand = () => (
+
+)
+
+export default TreeInitiallyOpenExampleShorthand
diff --git a/docs/src/examples/components/Tree/Types/TreeTitleCustomizationExample.shorthand.tsx b/docs/src/examples/components/Tree/Types/TreeTitleCustomizationExample.shorthand.tsx
new file mode 100644
index 0000000000..cc60148785
--- /dev/null
+++ b/docs/src/examples/components/Tree/Types/TreeTitleCustomizationExample.shorthand.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react'
+import { Icon, Tree } from '@stardust-ui/react'
+
+const items = [
+ {
+ id: '1',
+ title: 'one',
+ items: [
+ {
+ id: '2',
+ title: 'one one',
+ items: [
+ {
+ id: '3',
+ title: 'one one one',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: '4',
+ title: 'two',
+ items: [
+ {
+ id: '5',
+ title: 'two one',
+ },
+ ],
+ },
+]
+
+const titleRenderer = (Component, { content, open, hasSubtree, ...restProps }) => (
+
+ {hasSubtree && }
+ {content}
+
+)
+
+const TreeTitleCustomizationExample = () =>
+
+export default TreeTitleCustomizationExample
diff --git a/docs/src/examples/components/Tree/Types/index.tsx b/docs/src/examples/components/Tree/Types/index.tsx
new file mode 100644
index 0000000000..6698242482
--- /dev/null
+++ b/docs/src/examples/components/Tree/Types/index.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react'
+import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample'
+import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection'
+
+const Types = () => (
+
+
+
+
+)
+
+export default Types
diff --git a/docs/src/examples/components/Tree/Usage/index.tsx b/docs/src/examples/components/Tree/Usage/index.tsx
new file mode 100644
index 0000000000..0476340b31
--- /dev/null
+++ b/docs/src/examples/components/Tree/Usage/index.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react'
+
+import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample'
+import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection'
+
+const Usage = () => (
+
+
+
+
+)
+
+export default Usage
diff --git a/docs/src/examples/components/Tree/index.tsx b/docs/src/examples/components/Tree/index.tsx
new file mode 100644
index 0000000000..e6038d87de
--- /dev/null
+++ b/docs/src/examples/components/Tree/index.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react'
+import Types from './Types'
+
+const TreeExamples = () => (
+ <>
+
+ >
+)
+
+export default TreeExamples
diff --git a/packages/react/src/components/HierarchicalTree/HierarchicalTree.tsx b/packages/react/src/components/HierarchicalTree/HierarchicalTree.tsx
index fcbe883a2c..8f1722c9f7 100644
--- a/packages/react/src/components/HierarchicalTree/HierarchicalTree.tsx
+++ b/packages/react/src/components/HierarchicalTree/HierarchicalTree.tsx
@@ -95,7 +95,6 @@ class HierarchicalTree extends AutoControlledComponent<
exclusive: PropTypes.bool,
items: customPropTypes.collectionShorthand,
renderItemTitle: PropTypes.func,
- rtlAttributes: PropTypes.func,
onActiveIndexChange: PropTypes.func,
}
diff --git a/packages/react/src/components/HierarchicalTree/HierarchicalTreeItem.tsx b/packages/react/src/components/HierarchicalTree/HierarchicalTreeItem.tsx
index 4b3432d024..fafa7623a0 100644
--- a/packages/react/src/components/HierarchicalTree/HierarchicalTreeItem.tsx
+++ b/packages/react/src/components/HierarchicalTree/HierarchicalTreeItem.tsx
@@ -88,7 +88,6 @@ class HierarchicalTreeItem extends UIComponent
+
+ /**
+ * A custom render function for the title slot.
+ *
+ * @param {React.ReactType} Component - The computed component for this slot.
+ * @param {object} props - The computed props for this slot.
+ * @param {ReactNode|ReactNodeArray} children - The computed children for this slot.
+ */
+ renderItemTitle?: ShorthandRenderFunction
+}
+
+export interface TreeItemForRenderProps {
+ elementRef: React.RefObject
+ id: string
+ index: number
+ level: number
+ parent: ShorthandValue
+ siblings: ShorthandCollection
+}
+
+export interface TreeState {
+ activeItemIds: string[]
+ itemsForRender: Record
+}
+
+class Tree extends AutoControlledComponent, TreeState> {
+ static create: Function
+
+ static displayName = 'Tree'
+
+ static className = 'ui-tree'
+
+ static slotClassNames: TreeSlotClassNames = {
+ item: `${Tree.className}__item`,
+ }
+
+ static propTypes = {
+ ...commonPropTypes.createCommon({
+ content: false,
+ }),
+ activeItemIds: customPropTypes.collectionShorthand,
+ defaultActiveItemIds: customPropTypes.collectionShorthand,
+ exclusive: PropTypes.bool,
+ items: customPropTypes.collectionShorthand,
+ renderItemTitle: PropTypes.func,
+ }
+
+ static defaultProps = {
+ as: 'div',
+ accessibility: treeBehavior,
+ }
+
+ static autoControlledProps = ['activeItemIds']
+
+ // memoize this function if performance issue occurs.
+ static getItemsForRender = (itemsFromProps: ShorthandCollection) => {
+ const itemsForRenderGenerator = (
+ items = itemsFromProps,
+ level = 1,
+ parent?: ShorthandValue,
+ ) => {
+ return _.reduce(
+ items,
+ (acc: Object, item: ShorthandValue, index: number) => {
+ const id = item['id']
+ const isSubtree = hasSubtree(item)
+
+ acc[id] = {
+ elementRef: React.createRef(),
+ level,
+ index: index + 1, // Used for aria-posinset and it's 1-based.
+ parent,
+ siblings: items.filter(currentItem => currentItem !== item),
+ }
+
+ return {
+ ...acc,
+ ...(isSubtree ? itemsForRenderGenerator(item['items'], level + 1, item) : {}),
+ }
+ },
+ {},
+ )
+ }
+
+ return itemsForRenderGenerator(itemsFromProps)
+ }
+
+ static getAutoControlledStateFromProps(nextProps: TreeProps, prevState: TreeState) {
+ const itemsForRender = Tree.getItemsForRender(nextProps.items)
+
+ return {
+ itemsForRender,
+ }
+ }
+
+ getInitialAutoControlledState() {
+ return { activeItemIds: [] }
+ }
+
+ treeRef = React.createRef()
+
+ handleTreeItemOverrides = (predefinedProps: TreeItemProps) => ({
+ onTitleClick: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
+ if (!hasSubtree(treeItemProps)) {
+ return
+ }
+
+ let { activeItemIds } = this.state
+ const { id, siblings } = treeItemProps
+ const { exclusive } = this.props
+
+ const activeItemIdIndex = activeItemIds.indexOf(id)
+
+ if (activeItemIdIndex > -1) {
+ activeItemIds = removeItemAtIndex(activeItemIds, activeItemIdIndex)
+ } else {
+ if (exclusive) {
+ siblings.some(sibling => {
+ const activeSiblingIdIndex = activeItemIds.indexOf(sibling['id'])
+ if (activeSiblingIdIndex > -1) {
+ activeItemIds = removeItemAtIndex(activeItemIds, activeSiblingIdIndex)
+
+ return true
+ }
+ return false
+ })
+ }
+
+ activeItemIds = [...activeItemIds, id]
+ }
+
+ this.setState({
+ activeItemIds,
+ })
+
+ _.invoke(predefinedProps, 'onTitleClick', e, treeItemProps)
+ },
+ onFocusParent: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
+ const { parent } = treeItemProps
+
+ if (!parent) {
+ return
+ }
+
+ const { itemsForRender } = this.state
+ const parentItemForRender = itemsForRender[parent['id']]
+
+ if (
+ !parentItemForRender ||
+ !parentItemForRender.elementRef ||
+ !parentItemForRender.elementRef.current
+ ) {
+ return
+ }
+
+ parentItemForRender.elementRef.current.focus()
+ _.invoke(predefinedProps, 'onFocusParent', e, treeItemProps)
+ },
+ onFocusFirstChild: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
+ const { id } = treeItemProps
+
+ const { itemsForRender } = this.state
+ const currentElement = itemsForRender[id].elementRef
+
+ if (!currentElement || !currentElement.current) {
+ return
+ }
+
+ const elementToBeFocused = getNextElement(this.treeRef.current, currentElement.current)
+
+ if (!elementToBeFocused) {
+ return
+ }
+
+ elementToBeFocused.focus()
+ _.invoke(predefinedProps, 'onFocusFirstChild', e, treeItemProps)
+ },
+ onSiblingsExpand: (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => {
+ if (this.props.exclusive) {
+ return
+ }
+
+ const { id, siblings } = treeItemProps
+ const { activeItemIds } = this.state
+
+ siblings.forEach(sibling => {
+ if (hasSubtree(sibling) && !this.isActiveItem(sibling['id'])) {
+ activeItemIds.push(sibling['id'])
+ }
+ })
+
+ if (hasSubtree(treeItemProps) && !this.isActiveItem(id)) {
+ activeItemIds.push(id)
+ }
+
+ this.setState({
+ activeItemIds,
+ })
+
+ _.invoke(predefinedProps, 'onSiblingsExpand', e, treeItemProps)
+ },
+ })
+
+ renderContent(): React.ReactElement[] {
+ const { itemsForRender } = this.state
+ const { items, renderItemTitle } = this.props
+
+ if (!items) return null
+
+ const renderItems = (items: ShorthandCollection): React.ReactElement[] => {
+ return items.reduce(
+ (renderedItems: React.ReactElement[], item: ShorthandValue) => {
+ const itemForRender = itemsForRender[item['id']]
+ const { elementRef, ...restItemForRender } = itemForRender
+ const isSubtree = hasSubtree(item)
+ const isSubtreeOpen = isSubtree && this.isActiveItem(item['id'])
+
+ const renderedItem = TreeItem.create(item, {
+ defaultProps: {
+ className: Tree.slotClassNames.item,
+ open: isSubtreeOpen,
+ renderItemTitle,
+ key: item['id'],
+ ...restItemForRender,
+ },
+ overrideProps: this.handleTreeItemOverrides,
+ })
+
+ // Only need refs of the items that spawn subtrees, when they need to be focused
+ // by any of their children, using Arrow Left.
+ const finalRenderedItem = isSubtree ? (
+ [
+ {renderedItem}
+ ]
+ ) : (
+ renderedItem
+ )
+
+ return [
+ ...renderedItems,
+ finalRenderedItem,
+ ...[isSubtreeOpen ? renderItems(item['items']) : []],
+ ]
+ },
+ [],
+ )
+ }
+
+ return renderItems(items)
+ }
+
+ renderComponent({ ElementType, classes, accessibility, unhandledProps, styles, variables }) {
+ const { children } = this.props
+
+ return (
+ [
+
+ {childrenExist(children) ? children : this.renderContent()}
+
+ ]
+ )
+ }
+
+ isActiveItem = (id: string): boolean => {
+ const { activeItemIds } = this.state
+ return activeItemIds.indexOf(id) > -1
+ }
+}
+
+Tree.create = createShorthandFactory({
+ Component: Tree,
+ mappedArrayProp: 'items',
+})
+
+/**
+ * A Tree displays data organised in tree hierarchy.
+ *
+ * @accessibility
+ * Implements [ARIA TreeView](https://www.w3.org/TR/wai-aria-practices-1.1/#TreeView) design pattern.
+ */
+export default withSafeTypeForAs(Tree)
diff --git a/packages/react/src/components/Tree/TreeItem.tsx b/packages/react/src/components/Tree/TreeItem.tsx
new file mode 100644
index 0000000000..1b6b4947f4
--- /dev/null
+++ b/packages/react/src/components/Tree/TreeItem.tsx
@@ -0,0 +1,235 @@
+import * as customPropTypes from '@stardust-ui/react-proptypes'
+import * as _ from 'lodash'
+import * as PropTypes from 'prop-types'
+import * as React from 'react'
+
+import TreeTitle, { TreeTitleProps } from './TreeTitle'
+import { treeItemBehavior } from '../../lib/accessibility'
+import { Accessibility } from '../../lib/accessibility/types'
+import {
+ UIComponent,
+ childrenExist,
+ createShorthandFactory,
+ commonPropTypes,
+ UIComponentProps,
+ ChildrenComponentProps,
+ rtlTextContainer,
+ applyAccessibilityKeyHandlers,
+} from '../../lib'
+import {
+ ComponentEventHandler,
+ WithAsProp,
+ ShorthandRenderFunction,
+ ShorthandValue,
+ withSafeTypeForAs,
+ ShorthandCollection,
+} from '../../types'
+import { hasSubtree } from './lib'
+
+export interface TreeItemSlotClassNames {
+ title: string
+ subtree: string
+}
+
+export interface TreeItemProps extends UIComponentProps, ChildrenComponentProps {
+ /** Accessibility behavior if overridden by the user. */
+ accessibility?: Accessibility
+
+ /** Id needed to identify this item inside the Tree. */
+ id: string
+
+ /** The index of the item among its siblings. Count starts at 1. */
+ index?: number
+
+ /** Array of props for sub tree. */
+ items?: ShorthandCollection
+
+ /** Level of the tree/subtree that contains this item. */
+ level?: number
+
+ /** Called when a tree title is clicked. */
+ onTitleClick?: ComponentEventHandler
+
+ /** Called when the item's first child is about to be focused. */
+ onFocusFirstChild?: ComponentEventHandler
+
+ /** Called when the item's siblings are about to be expanded. */
+ onSiblingsExpand?: ComponentEventHandler
+
+ /** Called when the item's parent is about to be focused. */
+ onFocusParent?: ComponentEventHandler
+
+ /** Whether or not the item is in the open state. Only makes sense if item has children items. */
+ open?: boolean
+
+ /** The id of the parent tree item, if any. */
+ parent?: ShorthandValue
+
+ /** Array with the ids of the tree item's siblings, if any. */
+ siblings?: ShorthandCollection
+
+ /**
+ * A custom render iterator for rendering each tree title.
+ * The default component, props, and children are available for each tree title.
+ *
+ * @param {React.ReactType} Component - The computed component for this slot.
+ * @param {object} props - The computed props for this slot.
+ * @param {ReactNode|ReactNodeArray} children - The computed children for this slot.
+ */
+ renderItemTitle?: ShorthandRenderFunction
+
+ /** Properties for TreeTitle. */
+ title?: ShorthandValue
+}
+
+export interface TreeItemState {
+ treeSize: number // size of the tree without children.
+ hasSubtree: boolean
+}
+
+class TreeItem extends UIComponent, TreeItemState> {
+ static create: Function
+
+ static displayName = 'TreeItem'
+
+ static className = 'ui-tree__item'
+
+ static slotClassNames: TreeItemSlotClassNames = {
+ title: `${TreeItem.className}__title`,
+ subtree: `${TreeItem.className}__subtree`,
+ }
+
+ static propTypes = {
+ ...commonPropTypes.createCommon({
+ content: false,
+ }),
+ id: PropTypes.string.isRequired,
+ index: PropTypes.number,
+ items: customPropTypes.collectionShorthand,
+ level: PropTypes.number,
+ onTitleClick: PropTypes.func,
+ onFocusFirstChild: PropTypes.func,
+ onFocusParent: PropTypes.func,
+ onSiblingsExpand: PropTypes.func,
+ open: PropTypes.bool,
+ parent: customPropTypes.itemShorthand,
+ renderItemTitle: PropTypes.func,
+ siblings: customPropTypes.collectionShorthand,
+ title: customPropTypes.itemShorthand,
+ }
+
+ static defaultProps = {
+ as: 'div',
+ accessibility: treeItemBehavior,
+ }
+
+ state = {
+ hasSubtree: false,
+ treeSize: 0,
+ }
+
+ static getDerivedStateFromProps(props: TreeItemProps) {
+ return {
+ hasSubtree: hasSubtree(props),
+ treeSize: props.siblings.length + 1,
+ }
+ }
+
+ actionHandlers = {
+ performClick: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ this.handleTitleClick(e)
+ },
+ focusParent: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ _.invoke(this.props, 'onFocusParent', e, this.props)
+ },
+ collapse: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ this.handleTitleClick(e)
+ },
+ expand: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ this.handleTitleClick(e)
+ },
+ focusFirstChild: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ _.invoke(this.props, 'onFocusFirstChild', e, this.props)
+ },
+ expandSiblings: e => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ _.invoke(this.props, 'onSiblingsExpand', e, this.props)
+ },
+ }
+
+ handleTitleClick = e => {
+ _.invoke(this.props, 'onTitleClick', e, this.props)
+ }
+
+ handleTitleOverrides = (predefinedProps: TreeTitleProps) => ({
+ onClick: (e, titleProps) => {
+ this.handleTitleClick(e)
+ _.invoke(predefinedProps, 'onClick', e, titleProps)
+ },
+ })
+
+ renderContent() {
+ const { title, renderItemTitle, open, level, index } = this.props
+ const { hasSubtree, treeSize } = this.state
+
+ return TreeTitle.create(title, {
+ defaultProps: {
+ className: TreeItem.slotClassNames.title,
+ open,
+ hasSubtree,
+ as: hasSubtree ? 'span' : 'a',
+ level,
+ treeSize,
+ index,
+ },
+ render: renderItemTitle,
+ overrideProps: this.handleTitleOverrides,
+ })
+ }
+
+ renderComponent({ ElementType, accessibility, classes, unhandledProps, styles, variables }) {
+ const { children } = this.props
+
+ return (
+
+ {childrenExist(children) ? children : this.renderContent()}
+
+ )
+ }
+}
+
+TreeItem.create = createShorthandFactory({
+ Component: TreeItem,
+ mappedProp: 'title',
+})
+
+/**
+ * A TreeItem renders an item of a Tree.
+ *
+ * @accessibility
+ * Implements [ARIA TreeView](https://www.w3.org/TR/wai-aria-practices-1.1/#TreeView) design pattern.
+ */
+export default withSafeTypeForAs(TreeItem)
diff --git a/packages/react/src/components/Tree/TreeTitle.tsx b/packages/react/src/components/Tree/TreeTitle.tsx
new file mode 100644
index 0000000000..3fc5c6d8ea
--- /dev/null
+++ b/packages/react/src/components/Tree/TreeTitle.tsx
@@ -0,0 +1,110 @@
+import * as _ from 'lodash'
+import * as PropTypes from 'prop-types'
+import * as React from 'react'
+
+import {
+ UIComponent,
+ childrenExist,
+ createShorthandFactory,
+ commonPropTypes,
+ UIComponentProps,
+ ChildrenComponentProps,
+ ContentComponentProps,
+ rtlTextContainer,
+ applyAccessibilityKeyHandlers,
+} from '../../lib'
+import { treeTitleBehavior } from '../../lib/accessibility'
+import { Accessibility } from '../../lib/accessibility/types'
+import { ComponentEventHandler, WithAsProp, withSafeTypeForAs } from '../../types'
+
+export interface TreeTitleProps
+ extends UIComponentProps,
+ ChildrenComponentProps,
+ ContentComponentProps {
+ /** Accessibility behavior if overridden by the user. */
+ accessibility?: Accessibility
+
+ /** Whether or not the title has a subtree. */
+ hasSubtree?: boolean
+
+ /** The index of the title among its siblings. Count starts at 1. */
+ index?: number
+
+ /** Level of the tree/subtree that contains this title. */
+ level?: number
+
+ /**
+ * Called on click.
+ *
+ * @param {SyntheticEvent} event - React's original SyntheticEvent.
+ * @param {object} data - All props.
+ */
+ onClick?: ComponentEventHandler
+
+ /** Whether or not the subtree of the title is in the open state. */
+ open?: boolean
+
+ /** Size of the tree containing this title without any children. */
+ treeSize?: number
+}
+
+class TreeTitle extends UIComponent> {
+ static create: Function
+
+ static className = 'ui-tree__title'
+
+ static displayName = 'TreeTitle'
+
+ static propTypes = {
+ ...commonPropTypes.createCommon(),
+ hasSubtree: PropTypes.bool,
+ index: PropTypes.number,
+ level: PropTypes.number,
+ onClick: PropTypes.func,
+ open: PropTypes.bool,
+ treeSize: PropTypes.number,
+ }
+
+ static defaultProps = {
+ as: 'a',
+ accessibility: treeTitleBehavior,
+ }
+
+ actionHandlers = {
+ performClick: e => {
+ e.preventDefault()
+ this.handleClick(e)
+ },
+ }
+
+ handleClick = e => {
+ _.invoke(this.props, 'onClick', e, this.props)
+ }
+
+ renderComponent({ ElementType, classes, accessibility, unhandledProps, styles, variables }) {
+ const { children, content } = this.props
+
+ return (
+
+ {childrenExist(children) ? children : content}
+
+ )
+ }
+}
+
+TreeTitle.create = createShorthandFactory({
+ Component: TreeTitle,
+ mappedProp: 'content',
+})
+
+/**
+ * A TreeTitle renders a title of TreeItem.
+ */
+export default withSafeTypeForAs(TreeTitle)
diff --git a/packages/react/src/components/Tree/lib/index.ts b/packages/react/src/components/Tree/lib/index.ts
new file mode 100644
index 0000000000..7f2f62628e
--- /dev/null
+++ b/packages/react/src/components/Tree/lib/index.ts
@@ -0,0 +1,13 @@
+import * as _ from 'lodash'
+import { TreeItemProps } from '../TreeItem'
+import { ShorthandValue } from '../../../types'
+
+const hasSubtree = (item: TreeItemProps | ShorthandValue): boolean => {
+ return !_.isNil(item['items']) && item['items'].length > 0
+}
+
+const removeItemAtIndex = (items: any[], itemIndex: number): any[] => {
+ return [...items.slice(0, itemIndex), ...items.slice(itemIndex + 1)]
+}
+
+export { hasSubtree, removeItemAtIndex }
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 3bd7e42e08..e3bfc3cf2c 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -180,6 +180,13 @@ export {
default as HierarchicalTreeTitle,
} from './components/HierarchicalTree/HierarchicalTreeTitle'
+export * from './components/Tree/Tree'
+export { default as Tree } from './components/Tree/Tree'
+export * from './components/Tree/TreeItem'
+export { default as TreeItem } from './components/Tree/TreeItem'
+export * from './components/Tree/TreeTitle'
+export { default as TreeTitle } from './components/Tree/TreeTitle'
+
export * from './components/Reaction/Reaction'
export { default as Reaction } from './components/Reaction/Reaction'
export * from './components/Reaction/ReactionGroup'
diff --git a/packages/react/src/lib/accessibility/Behaviors/Tree/treeBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Tree/treeBehavior.ts
new file mode 100644
index 0000000000..320dcd1f94
--- /dev/null
+++ b/packages/react/src/lib/accessibility/Behaviors/Tree/treeBehavior.ts
@@ -0,0 +1,39 @@
+import * as keyboardKey from 'keyboard-key'
+import { Accessibility, AccessibilityAttributes, FocusZoneMode } from '../../types'
+import { FocusZoneDirection } from '../../FocusZone'
+
+/**
+ * @specification
+ * Adds role 'tree' to 'root' slot.
+ * Adds attribute 'aria-labelledby' based on the property 'aria-labelledby' to 'root' slot.
+ * Embeds component into FocusZone.
+ * Provides arrow key navigation in vertical direction.
+ * Triggers 'expandSiblings' action with '*' on 'root'.
+ */
+const treeBehavior: Accessibility = props => {
+ return {
+ attributes: {
+ root: {
+ role: 'tree',
+ 'aria-labelledby': props['aria-labelledby'],
+ },
+ },
+ keyActions: {
+ root: {
+ expandSiblings: {
+ keyCombinations: [{ keyCode: keyboardKey['*'] }],
+ },
+ },
+ },
+ focusZone: {
+ mode: FocusZoneMode.Embed,
+ props: {
+ direction: FocusZoneDirection.vertical,
+ },
+ },
+ }
+}
+
+type TreeBehaviorProps = {} & Pick
+
+export default treeBehavior
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..ba3702487b
--- /dev/null
+++ b/packages/react/src/lib/accessibility/Behaviors/Tree/treeItemBehavior.ts
@@ -0,0 +1,76 @@
+import { Accessibility } from '../../types'
+import * as keyboardKey from 'keyboard-key'
+import { IS_FOCUSABLE_ATTRIBUTE } from '../../FocusZone/focusUtilities'
+
+/**
+ * @description
+ * Adds role 'treeitem' to a non-leaf item and 'none' to a leaf item.
+ * Adds 'aria-expanded' with a value based on the 'open' prop if item is not a leaf.
+ * Adds 'tabIndex' as '-1' if the item is not a leaf.
+ *
+ * @specification
+ * Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'.
+ * Triggers 'focusParent' action with 'ArrowLeft' on 'root', when has a closed subtree.
+ * Triggers 'collapse' action with 'ArrowLeft' on 'root', when has an opened subtree.
+ * Triggers 'expand' action with 'ArrowRight' on 'root', when has a closed subtree.
+ * Triggers 'focusFirstChild' action with 'ArrowRight' on 'root', when has an opened subtree.
+ */
+const treeItemBehavior: Accessibility = props => ({
+ attributes: {
+ root: {
+ role: 'none',
+ ...(props.hasSubtree && {
+ 'aria-expanded': props.open,
+ tabIndex: -1,
+ [IS_FOCUSABLE_ATTRIBUTE]: true,
+ role: 'treeitem',
+ 'aria-setsize': props.treeSize,
+ 'aria-posinset': props.index,
+ 'aria-level': props.level,
+ }),
+ },
+ },
+ keyActions: {
+ root: {
+ performClick: {
+ keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }],
+ },
+ ...(isSubtreeOpen(props) && {
+ collapse: {
+ keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
+ },
+ focusFirstChild: {
+ keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
+ },
+ }),
+ ...(!isSubtreeOpen(props) && {
+ expand: {
+ keyCombinations: [{ keyCode: keyboardKey.ArrowRight }],
+ },
+ focusParent: {
+ keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }],
+ },
+ }),
+ expandSiblings: {
+ keyCombinations: [{ keyCode: keyboardKey['*'] }],
+ },
+ },
+ },
+})
+
+export type TreeItemBehaviorProps = {
+ /** If item is a subtree, it indicates if it's open. */
+ open?: boolean
+ level?: number
+ index?: number
+ hasSubtree?: boolean
+ treeSize?: number
+}
+
+/** Checks if current tree item has a subtree and it is opened */
+const isSubtreeOpen = (props: TreeItemBehaviorProps): boolean => {
+ const { hasSubtree, open } = props
+ return !!(hasSubtree && open)
+}
+
+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
new file mode 100644
index 0000000000..47708b2640
--- /dev/null
+++ b/packages/react/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts
@@ -0,0 +1,43 @@
+import { Accessibility } from '../../types'
+import * as keyboardKey from 'keyboard-key'
+import { IS_FOCUSABLE_ATTRIBUTE } from '../../FocusZone/focusUtilities'
+
+/**
+ * @description
+ * Adds role 'treeitem' if the title is a leaf node inside the tree.
+ * Adds 'tabIndex' as '-1' if the title is a leaf node inside the tree.
+ *
+ * @specification
+ * Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'.
+ */
+const treeTitleBehavior: Accessibility = props => ({
+ attributes: {
+ root: {
+ ...(!props.hasSubtree && {
+ tabIndex: -1,
+ [IS_FOCUSABLE_ATTRIBUTE]: true,
+ role: 'treeitem',
+ 'aria-setsize': props.treeSize,
+ 'aria-posinset': props.index,
+ 'aria-level': props.level,
+ }),
+ },
+ },
+ keyActions: {
+ root: {
+ performClick: {
+ keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }],
+ },
+ },
+ },
+})
+
+export default treeTitleBehavior
+
+type TreeTitleBehavior = {
+ /** Indicated if tree title has a subtree */
+ hasSubtree?: boolean
+ level?: number
+ treeSize?: number
+ index?: number
+}
diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts
index 2e55b6e5ec..366b3dc359 100644
--- a/packages/react/src/lib/accessibility/index.ts
+++ b/packages/react/src/lib/accessibility/index.ts
@@ -59,3 +59,6 @@ export { default as tooltipBehavior } from './Behaviors/Tooltip/tooltipBehavior'
export { default as tooltipAsLabelBehavior } from './Behaviors/Tooltip/tooltipAsLabelBehavior'
export { default as sliderBehavior } from './Behaviors/Slider/sliderBehavior'
export { default as menuButtonBehavior } from './Behaviors/MenuButton/menuButtonBehavior'
+export { default as treeBehavior } from './Behaviors/Tree/treeBehavior'
+export { default as treeItemBehavior } from './Behaviors/Tree/treeItemBehavior'
+export { default as treeTitleBehavior } from './Behaviors/Tree/treeTitleBehavior'
diff --git a/packages/react/src/themes/teams/componentStyles.ts b/packages/react/src/themes/teams/componentStyles.ts
index 05bd31bf0d..9bdf9a8d93 100644
--- a/packages/react/src/themes/teams/componentStyles.ts
+++ b/packages/react/src/themes/teams/componentStyles.ts
@@ -36,6 +36,14 @@ export { default as Grid } from './components/Grid/gridStyles'
export { default as Header } from './components/Header/headerStyles'
export { default as HeaderDescription } from './components/Header/headerDescriptionStyles'
+export { default as HierarchicalTree } from './components/HierarchicalTree/hierarchicalTreeStyles'
+export {
+ default as HierarchicalTreeItem,
+} from './components/HierarchicalTree/hierarchicalTreeItemStyles'
+export {
+ default as HierarchicalTreeTitle,
+} from './components/HierarchicalTree/hierarchicalTreeTitleStyles'
+
export { default as Icon } from './components/Icon/iconStyles'
export { default as Label } from './components/Label/labelStyles'
@@ -80,13 +88,9 @@ export { default as ToolbarMenu } from './components/Toolbar/toolbarMenuStyles'
export { default as ToolbarMenuDivider } from './components/Toolbar/toolbarMenuDividerStyles'
export { default as ToolbarMenuItem } from './components/Toolbar/toolbarMenuItemStyles'
-export { default as HierarchicalTree } from './components/HierarchicalTree/hierarchicalTreeStyles'
-export {
- default as HierarchicalTreeItem,
-} from './components/HierarchicalTree/hierarchicalTreeItemStyles'
-export {
- default as HierarchicalTreeTitle,
-} from './components/HierarchicalTree/hierarchicalTreeTitleStyles'
+export { default as Tree } from './components/Tree/treeStyles'
+export { default as TreeItem } from './components/Tree/treeItemStyles'
+export { default as TreeTitle } from './components/Tree/treeTitleStyles'
export { default as Animation } from './components/Animation/animationStyles'
diff --git a/packages/react/src/themes/teams/componentVariables.ts b/packages/react/src/themes/teams/componentVariables.ts
index e8512b1e4d..9e9eab9f4c 100644
--- a/packages/react/src/themes/teams/componentVariables.ts
+++ b/packages/react/src/themes/teams/componentVariables.ts
@@ -31,6 +31,10 @@ export { default as Embed } from './components/Embed/embedVariables'
export { default as Header } from './components/Header/headerVariables'
export { default as HeaderDescription } from './components/Header/headerDescriptionVariables'
+export {
+ default as HierarchicalTreeTitle,
+} from './components/HierarchicalTree/hierarchicalTreeTitleVariables'
+
export { default as Icon } from './components/Icon/iconVariables'
export { default as Input } from './components/Input/inputVariables'
@@ -75,9 +79,7 @@ export { default as ToolbarMenu } from './components/Toolbar/toolbarMenuVariable
export { default as ToolbarMenuDivider } from './components/Toolbar/toolbarMenuDividerVariables'
export { default as ToolbarMenuItem } from './components/Toolbar/toolbarMenuItemVariables'
-export {
- default as HierarchicalTreeTitle,
-} from './components/HierarchicalTree/hierarchicalTreeTitleVariables'
+export { default as TreeTitle } from './components/Tree/treeTitleVariables'
export { default as Animation } from './components/Animation/animationVariables'
diff --git a/packages/react/src/themes/teams/components/Tree/treeItemStyles.ts b/packages/react/src/themes/teams/components/Tree/treeItemStyles.ts
new file mode 100644
index 0000000000..560033b4b4
--- /dev/null
+++ b/packages/react/src/themes/teams/components/Tree/treeItemStyles.ts
@@ -0,0 +1,24 @@
+import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types'
+import { pxToRem } from '../../../../lib'
+import getBorderFocusStyles from '../../getBorderFocusStyles'
+import { TreeItemProps } from '../../../../components/Tree/TreeItem'
+import TreeTitle from '../../../../components/Tree/TreeTitle'
+
+const treeItemStyles: ComponentSlotStylesInput = {
+ root: ({ theme: { siteVariables }, props: p }): ICSSInJSStyle => ({
+ listStyleType: 'none',
+ padding: `0 0 0 ${pxToRem(1 + (p.level - 1) * 10)}`,
+ ':focus': {
+ outline: 0,
+ [`> .${TreeTitle.className}`]: {
+ position: 'relative',
+ ...getBorderFocusStyles({
+ siteVariables,
+ isFromKeyboard: true,
+ })[':focus'],
+ },
+ },
+ }),
+}
+
+export default treeItemStyles
diff --git a/packages/react/src/themes/teams/components/Tree/treeStyles.ts b/packages/react/src/themes/teams/components/Tree/treeStyles.ts
new file mode 100644
index 0000000000..713956a787
--- /dev/null
+++ b/packages/react/src/themes/teams/components/Tree/treeStyles.ts
@@ -0,0 +1,11 @@
+import { ICSSInJSStyle } from '../../../types'
+import { pxToRem } from '../../../../lib'
+
+const treeStyles = {
+ root: (): ICSSInJSStyle => ({
+ display: 'block',
+ paddingLeft: `${pxToRem(10)}`,
+ }),
+}
+
+export default treeStyles
diff --git a/packages/react/src/themes/teams/components/Tree/treeTitleStyles.ts b/packages/react/src/themes/teams/components/Tree/treeTitleStyles.ts
new file mode 100644
index 0000000000..89d375bb89
--- /dev/null
+++ b/packages/react/src/themes/teams/components/Tree/treeTitleStyles.ts
@@ -0,0 +1,17 @@
+import { ICSSInJSStyle } from '../../../types'
+import getBorderFocusStyles from '../../getBorderFocusStyles'
+
+const treeTitleStyles = {
+ root: ({ variables: v, theme: { siteVariables } }): ICSSInJSStyle => ({
+ padding: v.padding,
+ cursor: 'pointer',
+ color: v.color,
+ position: 'relative',
+ ...getBorderFocusStyles({
+ siteVariables,
+ isFromKeyboard: true,
+ }),
+ }),
+}
+
+export default treeTitleStyles
diff --git a/packages/react/src/themes/teams/components/Tree/treeTitleVariables.ts b/packages/react/src/themes/teams/components/Tree/treeTitleVariables.ts
new file mode 100644
index 0000000000..f59909f119
--- /dev/null
+++ b/packages/react/src/themes/teams/components/Tree/treeTitleVariables.ts
@@ -0,0 +1,13 @@
+import { pxToRem } from '../../../../lib'
+
+export interface TreeTitleVariables {
+ color: string
+ padding: string
+}
+
+export default (siteVars: any): TreeTitleVariables => {
+ return {
+ color: siteVars.colorScheme.default.foreground,
+ padding: `${pxToRem(1)} 0`,
+ }
+}
diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx
index 2b09c94c14..3edfaa1700 100644
--- a/packages/react/test/specs/behaviors/behavior-test.tsx
+++ b/packages/react/test/specs/behaviors/behavior-test.tsx
@@ -51,6 +51,9 @@ import {
tooltipBehavior,
tooltipAsLabelBehavior,
menuButtonBehavior,
+ treeBehavior,
+ treeItemBehavior,
+ treeTitleBehavior,
} from 'src/lib/accessibility'
import { TestHelper } from './testHelper'
import definitions from './testDefinitions'
@@ -106,5 +109,8 @@ testHelper.addBehavior('toolbarRadioGroupBehavior', toolbarRadioGroupBehavior)
testHelper.addBehavior('toolbarRadioGroupItemBehavior', toolbarRadioGroupItemBehavior)
testHelper.addBehavior('tooltipBehavior', tooltipBehavior)
testHelper.addBehavior('tooltipAsLabelBehavior', tooltipAsLabelBehavior)
+testHelper.addBehavior('treeBehavior', treeBehavior)
+testHelper.addBehavior('treeItemBehavior', treeItemBehavior)
+testHelper.addBehavior('treeTitleBehavior', treeTitleBehavior)
testHelper.run(behaviorMenuItems)
diff --git a/packages/react/test/specs/behaviors/testDefinitions.ts b/packages/react/test/specs/behaviors/testDefinitions.ts
index ca5048b19e..bc649a3b46 100644
--- a/packages/react/test/specs/behaviors/testDefinitions.ts
+++ b/packages/react/test/specs/behaviors/testDefinitions.ts
@@ -558,7 +558,7 @@ definitions.push({
regexp: /Triggers '(\w+)' action with '(\w+)' on '([\w-]+)', when has an opened subtree\./g,
testMethod: (parameters: TestMethod) => {
const [action, key, elementToPerformAction] = [...parameters.props]
- const propertyOpenedSubtree = { open: true, items: [{ a: 1 }] }
+ const propertyOpenedSubtree = { open: true, items: [{ a: 1 }], siblings: [], hasSubtree: true }
const expectedKeyNumberVertical = parameters.behavior(propertyOpenedSubtree).keyActions[
elementToPerformAction
][action].keyCombinations[0].keyCode
@@ -571,7 +571,7 @@ definitions.push({
regexp: /Triggers '(\w+)' action with '(\w+)' on '([\w-]+)', when has a closed subtree\./g,
testMethod: (parameters: TestMethod) => {
const [action, key, elementToPerformAction] = [...parameters.props]
- const propertyClosedSubtree = { open: false }
+ const propertyClosedSubtree = { open: false, hasSubtree: false }
const expectedKeyNumberVertical = parameters.behavior(propertyClosedSubtree).keyActions[
elementToPerformAction
][action].keyCombinations[0].keyCode
diff --git a/packages/react/test/specs/components/Tree/Tree-test.tsx b/packages/react/test/specs/components/Tree/Tree-test.tsx
new file mode 100644
index 0000000000..7818b7d5d3
--- /dev/null
+++ b/packages/react/test/specs/components/Tree/Tree-test.tsx
@@ -0,0 +1,160 @@
+import * as React from 'react'
+import * as keyboardKey from 'keyboard-key'
+
+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 TreeItem from 'src/components/Tree/TreeItem'
+import { ReactWrapper, CommonWrapper } from 'enzyme'
+
+const items = [
+ {
+ id: '1',
+ title: '1',
+ items: [
+ {
+ id: '11',
+ title: '11',
+ },
+ {
+ id: '12',
+ title: '12',
+ items: [
+ {
+ id: '121',
+ title: '121',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: '2',
+ title: '2',
+ items: [
+ {
+ id: '21',
+ title: '21',
+ items: [
+ {
+ id: '211',
+ title: '211',
+ },
+ ],
+ },
+ {
+ id: '22',
+ title: '22',
+ },
+ ],
+ },
+ {
+ id: '3',
+ title: '3',
+ },
+]
+
+const getTitles = (wrapper: ReactWrapper): CommonWrapper =>
+ wrapper.find(`.${TreeTitle.className}`).filterWhere(n => typeof n.type() === 'string')
+const getItems = (wrapper: ReactWrapper): CommonWrapper =>
+ wrapper.find(`.${TreeItem.className}`).filterWhere(n => typeof n.type() === 'string')
+
+const checkOpenTitles = (wrapper: ReactWrapper, expected: string[]): void => {
+ const titles = getTitles(wrapper)
+ expect(titles.length).toEqual(expected.length)
+
+ expected.forEach((expectedTitle, index) => {
+ expect(titles.at(index).getDOMNode().textContent).toEqual(expectedTitle)
+ })
+}
+
+describe('Tree', () => {
+ isConformant(Tree)
+
+ describe('activeItemIds', () => {
+ it('should contain index of item open at click', () => {
+ const wrapper = mountWithProvider()
+
+ getTitles(wrapper)
+ .at(0) // title 1
+ .simulate('click')
+ checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
+
+ getTitles(wrapper)
+ .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()
+
+ getTitles(wrapper)
+ .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()
+
+ getTitles(wrapper)
+ .at(0) // title 1
+ .simulate('click')
+ checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
+
+ getTitles(wrapper)
+ .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()
+
+ getTitles(wrapper)
+ .at(0) // title 1
+ .simulate('keydown', { keyCode: keyboardKey.ArrowRight })
+ checkOpenTitles(wrapper, ['1', '11', '12', '2', '3'])
+ })
+
+ it('should have index of item removed if closed by ArrowLeft', () => {
+ const wrapper = mountWithProvider()
+
+ getItems(wrapper)
+ .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()
+
+ getTitles(wrapper)
+ .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()
+
+ getTitles(wrapper)
+ .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(
+ ,
+ )
+
+ getTitles(wrapper)
+ .at(0) // title 1
+ .simulate('keydown', { keyCode: keyboardKey['*'] })
+ checkOpenTitles(wrapper, ['1', '11', '12', '2', '21', '22', '3'])
+ })
+ })
+})