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']) + }) + }) +})