diff --git a/CHANGELOG.md b/CHANGELOG.md index c48ed22c61..9217cf3427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Features +- Add `Tree` Component @priyankar205 ([#479] +(https://github.com/stardust-ui/react/pull/479)) + ## [v0.13.0](https://github.com/stardust-ui/react/tree/v0.13.0) (2018-11-27) [Compare changes](https://github.com/stardust-ui/react/compare/v0.12.1...v0.13.0) 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..aef1cbc7f4 --- /dev/null +++ b/docs/src/examples/components/Tree/Types/TreeExample.shorthand.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { Tree } from '@stardust-ui/react' + +const items = [ + { + key: '1', + title: 'one', + items: [ + { + key: '1', + title: 'one one', + items: [ + { + key: '1', + title: 'one one one', + }, + ], + }, + ], + }, + { + key: '2', + title: ' two', + items: [ + { + key: '1', + title: 'two one', + }, + ], + }, +] + +const TreeExampleShorthand = () => + +export default TreeExampleShorthand 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..c9d1e4374a --- /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 = [ + { + key: '1', + title: 'one', + items: [ + { + key: '2', + title: 'one one', + items: [ + { + key: '3', + title: 'one one one', + }, + ], + }, + ], + }, + { + key: '4', + title: 'two', + items: [ + { + key: '5', + title: 'two one', + }, + ], + }, +] + +const titleRenderer = (Component, { content, open, hasSubtree, ...rest }) => ( + + {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..e6ba4e8529 --- /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/index.tsx b/docs/src/examples/components/Tree/index.tsx new file mode 100644 index 0000000000..1682fecab8 --- /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/src/components/Tree/Tree.tsx b/src/components/Tree/Tree.tsx new file mode 100644 index 0000000000..62404acfbd --- /dev/null +++ b/src/components/Tree/Tree.tsx @@ -0,0 +1,80 @@ +import * as _ from 'lodash' +import * as PropTypes from 'prop-types' +import * as React from 'react' + +import TreeItem from './TreeItem' +import { UIComponent, childrenExist } from '../../lib' +import { ShorthandValue, ShorthandRenderFunction } from '../../../types/utils' +import { Accessibility } from '../../lib/accessibility/types' +import { defaultBehavior } from '../../lib/accessibility' +import { commonUIComponentPropTypes, childrenComponentPropTypes } from '../../lib/commonPropTypes' +import { UIComponentProps, ChildrenComponentProps } from '../../lib/commonPropInterfaces' +import * as customPropTypes from '../../lib/customPropTypes' + +export interface TreeProps extends UIComponentProps, ChildrenComponentProps { + /** + * Accessibility behavior if overridden by the user. + * @default defaultBehavior + */ + accessibility?: Accessibility + + /** Shorthand array of props for Tree. */ + items: ShorthandValue[] + + /** + * 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 +} + +/** + * Allows users to display data organised in tree-hierarchy. + */ +class Tree extends UIComponent { + static create: Function + + static className = 'ui-tree' + + static displayName = 'Tree' + + static propTypes = { + ...commonUIComponentPropTypes, + ...childrenComponentPropTypes, + accessibility: PropTypes.func, + items: customPropTypes.collectionShorthand, + renderItemTitle: PropTypes.func, + } + + public static defaultProps = { + as: 'ul', + accessibility: defaultBehavior, + } + + renderContent() { + const { items, renderItemTitle } = this.props + + return _.map(items, item => + TreeItem.create(item, { + defaultProps: { + renderItemTitle, + }, + }), + ) + } + + renderComponent({ ElementType, classes, accessibility, rest, styles, variables }) { + const { children } = this.props + + return ( + + {childrenExist(children) ? children : this.renderContent()} + + ) + } +} + +export default Tree diff --git a/src/components/Tree/TreeItem.tsx b/src/components/Tree/TreeItem.tsx new file mode 100644 index 0000000000..02c9230a87 --- /dev/null +++ b/src/components/Tree/TreeItem.tsx @@ -0,0 +1,130 @@ +import * as _ from 'lodash' +import * as PropTypes from 'prop-types' +import * as React from 'react' + +import Tree from './Tree' +import TreeTitle from './TreeTitle' +import { defaultBehavior } from '../../lib/accessibility' +import { Accessibility } from '../../lib/accessibility/types' + +import { + AutoControlledComponent, + childrenExist, + customPropTypes, + createShorthandFactory, +} from '../../lib' +import { ShorthandRenderFunction, ShorthandValue } from 'utils' +import { commonUIComponentPropTypes, childrenComponentPropTypes } from '../../lib/commonPropTypes' +import { + UIComponentProps, + ChildrenComponentProps, + ContentComponentProps, +} from '../../lib/commonPropInterfaces' + +export interface TreeItemProps + extends UIComponentProps, + ChildrenComponentProps, + ContentComponentProps { + /** + * Accessibility behavior if overridden by the user. + * @default defaultBehavior + */ + accessibility?: Accessibility + + /** Initial open value. */ + defaultOpen?: boolean + + /** Array of props for sub tree. */ + items?: ShorthandValue[] + + /** Whether or not the subtree of the item is in the open state. */ + open?: boolean + + /** + * A custom render iterator for rendering each Accordion panel title. + * The default component, props, and children are available for each panel 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 { + open?: boolean +} + +class TreeItem extends AutoControlledComponent { + static create: Function + + static className = 'ui-tree__item' + + static displayName = 'TreeItem' + + static autoControlledProps = ['open'] + + static propTypes = { + ...commonUIComponentPropTypes, + ...childrenComponentPropTypes, + accessibility: PropTypes.func, + defaultOpen: PropTypes.bool, + items: customPropTypes.collectionShorthand, + open: PropTypes.bool, + renderItemTitle: PropTypes.func, + title: customPropTypes.itemShorthand, + } + + public static defaultProps = { + as: 'li', + accessibility: defaultBehavior, + } + + handleTitleOverrides = predefinedProps => ({ + onClick: (e, titleProps) => { + e.preventDefault() + this.trySetState({ + open: !this.state.open, + }) + _.invoke(predefinedProps, 'onClick', e, titleProps) + }, + }) + + renderContent() { + const { items, title, renderItemTitle } = this.props + const { open } = this.state + + const hasSubtree = !!(items && items.length) + + return ( + <> + {TreeTitle.create(title, { + defaultProps: { + open, + hasSubtree, + }, + render: renderItemTitle, + overrideProps: this.handleTitleOverrides, + })} + {hasSubtree && open && } + + ) + } + + renderComponent({ ElementType, accessibility, classes, rest, styles, variables }) { + const { children } = this.props + + return ( + + {childrenExist(children) ? children : this.renderContent()} + + ) + } +} + +TreeItem.create = createShorthandFactory(TreeItem, 'title') + +export default TreeItem diff --git a/src/components/Tree/TreeTitle.tsx b/src/components/Tree/TreeTitle.tsx new file mode 100644 index 0000000000..a9069eeb13 --- /dev/null +++ b/src/components/Tree/TreeTitle.tsx @@ -0,0 +1,80 @@ +import * as _ from 'lodash' +import * as PropTypes from 'prop-types' +import * as React from 'react' + +import { UIComponent, childrenExist, createShorthandFactory } from '../../lib' +import { treeTitleBehavior } from '../../lib/accessibility' +import { Accessibility } from '../../lib/accessibility/types' +import { + commonUIComponentPropTypes, + childrenComponentPropTypes, + contentComponentPropsTypes, +} from '../../lib/commonPropTypes' +import { + UIComponentProps, + ChildrenComponentProps, + ContentComponentProps, +} from '../../lib/commonPropInterfaces' + +export interface TreeTitleProps + extends UIComponentProps, + ChildrenComponentProps, + ContentComponentProps { + /** + * Accessibility behavior if overridden by the user. + * @default treeTitleBehavior + */ + accessibility?: Accessibility + + /** Whether or not the subtree of the item is in the open state. */ + open?: boolean + + /** Whether or not the item has a subtree. */ + hasSubtree?: boolean +} + +class TreeTitle extends UIComponent { + static create: Function + + static className = 'ui-tree__title' + + static displayName = 'TreeTitle' + + static propTypes = { + ...commonUIComponentPropTypes, + ...childrenComponentPropTypes, + ...contentComponentPropsTypes, + accessibility: PropTypes.func, + open: PropTypes.bool, + hasSubtree: PropTypes.bool, + } + + public static defaultProps = { + as: 'a', + href: '#', + accessibility: treeTitleBehavior, + } + + handleClick = e => { + _.invoke(this.props, 'onClick', e, this.props) + } + + renderComponent({ ElementType, classes, accessibility, rest, styles, variables }) { + const { children, content } = this.props + + return ( + + {childrenExist(children) ? children : content} + + ) + } +} + +TreeTitle.create = createShorthandFactory(TreeTitle, 'content') + +export default TreeTitle diff --git a/src/components/Tree/index.ts b/src/components/Tree/index.ts new file mode 100644 index 0000000000..36b7c7b123 --- /dev/null +++ b/src/components/Tree/index.ts @@ -0,0 +1 @@ +export { default } from './Tree' diff --git a/src/index.ts b/src/index.ts index b1f36f478e..be1fb6c51b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,8 @@ export { default as Text, TextProps } from './components/Text/Text' export { default as Animation, AnimationProps } from './components/Animation/Animation' +export { default as Tree } from './components/Tree' + // // Accessibility // diff --git a/src/lib/UIComponent.tsx b/src/lib/UIComponent.tsx index 1b62e4cbca..cc48d6b162 100644 --- a/src/lib/UIComponent.tsx +++ b/src/lib/UIComponent.tsx @@ -5,7 +5,7 @@ import { AccessibilityActionHandlers } from './accessibility/types' import { FocusZone } from './accessibility/FocusZone' // TODO @Bugaa92: deprecated by createComponent.tsx -class UIComponent extends React.Component { +class UIComponent extends React.Component { private readonly childClass = this.constructor as typeof UIComponent static defaultProps: { [key: string]: any } static displayName: string diff --git a/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts b/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts new file mode 100644 index 0000000000..1358cc9c50 --- /dev/null +++ b/src/lib/accessibility/Behaviors/Tree/treeTitleBehavior.ts @@ -0,0 +1,15 @@ +import { Accessibility } from '../../types' + +/** + * @specification + * Adds attribute 'aria-expanded=true' based on the property 'open' if the component has 'hasSubtree' property. + */ +const treeTitleBehavior: Accessibility = (props: any) => ({ + attributes: { + root: { + ...(props.hasSubtree && { 'aria-expanded': props.open ? 'true' : 'false' }), + }, + }, +}) + +export default treeTitleBehavior diff --git a/src/lib/accessibility/index.ts b/src/lib/accessibility/index.ts index fc8c7f4904..0562467be6 100644 --- a/src/lib/accessibility/index.ts +++ b/src/lib/accessibility/index.ts @@ -24,4 +24,5 @@ export { default as popupFocusTrapBehavior } from './Behaviors/Popup/popupFocusT 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 treeTitleBehavior } from './Behaviors/Tree/treeTitleBehavior' export { default as dialogBehavior } from './Behaviors/Dialog/dialogBehavior' diff --git a/src/themes/teams-dark/componentVariables.ts b/src/themes/teams-dark/componentVariables.ts index a91c112954..8e3ec3ed40 100644 --- a/src/themes/teams-dark/componentVariables.ts +++ b/src/themes/teams-dark/componentVariables.ts @@ -3,3 +3,4 @@ export { default as Divider } from './components/Divider/dividerVariables' export { default as Header } from './components/Header/headerVariables' export { default as Input } from './components/Input/inputVariables' export { default as Text } from './components/Text/textVariables' +export { default as TreeTitle } from './components/Tree/treeTitleVariables' diff --git a/src/themes/teams-dark/components/Tree/treeTitleVariables.ts b/src/themes/teams-dark/components/Tree/treeTitleVariables.ts new file mode 100644 index 0000000000..e8cce3da52 --- /dev/null +++ b/src/themes/teams-dark/components/Tree/treeTitleVariables.ts @@ -0,0 +1,7 @@ +import { TreeTitleVariables } from '../../../teams/components/Tree/treeTitleVariables' + +export default (siteVars: any): TreeTitleVariables => { + return { + defaultColor: siteVars.white, + } +} diff --git a/src/themes/teams-high-contrast/componentVariables.ts b/src/themes/teams-high-contrast/componentVariables.ts index ccc007c5b0..38bb4040b9 100644 --- a/src/themes/teams-high-contrast/componentVariables.ts +++ b/src/themes/teams-high-contrast/componentVariables.ts @@ -6,3 +6,4 @@ export { default as Header } from './components/Header/headerVariables' export { default as Input } from './components/Input/inputVariables' export { default as Text } from './components/Text/textVariables' +export { default as TreeTitle } from './components/Tree/treeTitleVariables' diff --git a/src/themes/teams-high-contrast/components/Tree/treeTitleVariables.ts b/src/themes/teams-high-contrast/components/Tree/treeTitleVariables.ts new file mode 100644 index 0000000000..e8cce3da52 --- /dev/null +++ b/src/themes/teams-high-contrast/components/Tree/treeTitleVariables.ts @@ -0,0 +1,7 @@ +import { TreeTitleVariables } from '../../../teams/components/Tree/treeTitleVariables' + +export default (siteVars: any): TreeTitleVariables => { + return { + defaultColor: siteVars.white, + } +} diff --git a/src/themes/teams/componentStyles.ts b/src/themes/teams/componentStyles.ts index ff31420305..4490cc07e2 100644 --- a/src/themes/teams/componentStyles.ts +++ b/src/themes/teams/componentStyles.ts @@ -52,4 +52,8 @@ export { default as Status } from './components/Status/statusStyles' export { default as Text } from './components/Text/textStyles' +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/src/themes/teams/componentVariables.ts b/src/themes/teams/componentVariables.ts index eeb611e45a..384724a913 100644 --- a/src/themes/teams/componentVariables.ts +++ b/src/themes/teams/componentVariables.ts @@ -47,4 +47,6 @@ export { default as Status } from './components/Status/statusVariables' export { default as Text } from './components/Text/textVariables' +export { default as TreeTitle } from './components/Tree/treeTitleVariables' + export { default as Animation } from './components/Animation/animationVariables' diff --git a/src/themes/teams/components/Tree/treeItemStyles.ts b/src/themes/teams/components/Tree/treeItemStyles.ts new file mode 100644 index 0000000000..7b981ed410 --- /dev/null +++ b/src/themes/teams/components/Tree/treeItemStyles.ts @@ -0,0 +1,11 @@ +import { ICSSInJSStyle } from '../../../types' +import { pxToRem } from '../../../../lib' + +const treeItemStyles = { + root: (): ICSSInJSStyle => ({ + listStyleType: 'none', + padding: `0 0 0 ${pxToRem(1)}`, + }), +} + +export default treeItemStyles diff --git a/src/themes/teams/components/Tree/treeStyles.ts b/src/themes/teams/components/Tree/treeStyles.ts new file mode 100644 index 0000000000..713956a787 --- /dev/null +++ b/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/src/themes/teams/components/Tree/treeTitleStyles.ts b/src/themes/teams/components/Tree/treeTitleStyles.ts new file mode 100644 index 0000000000..3efdfbd680 --- /dev/null +++ b/src/themes/teams/components/Tree/treeTitleStyles.ts @@ -0,0 +1,12 @@ +import { ICSSInJSStyle } from '../../../types' +import { pxToRem } from '../../../../lib' + +const treeTitleStyles = { + root: ({ variables }): ICSSInJSStyle => ({ + padding: `${pxToRem(1)} 0`, + cursor: 'pointer', + color: variables.defaultColor, + }), +} + +export default treeTitleStyles diff --git a/src/themes/teams/components/Tree/treeTitleVariables.ts b/src/themes/teams/components/Tree/treeTitleVariables.ts new file mode 100644 index 0000000000..cb80ee65ce --- /dev/null +++ b/src/themes/teams/components/Tree/treeTitleVariables.ts @@ -0,0 +1,9 @@ +export interface TreeTitleVariables { + defaultColor: string +} + +export default (siteVars: any): TreeTitleVariables => { + return { + defaultColor: siteVars.black, + } +} diff --git a/test/specs/behaviors/behavior-test.tsx b/test/specs/behaviors/behavior-test.tsx index bc38e2ddc5..a2951092cf 100644 --- a/test/specs/behaviors/behavior-test.tsx +++ b/test/specs/behaviors/behavior-test.tsx @@ -26,6 +26,7 @@ import { toggleButtonBehavior, toolbarBehavior, toolbarButtonBehavior, + treeTitleBehavior, gridBehavior, } from 'src/lib/accessibility' import { TestHelper } from './testHelper' @@ -55,6 +56,7 @@ testHelper.addBehavior('tabListBehavior', tabListBehavior) testHelper.addBehavior('toolbarBehavior', toolbarBehavior) testHelper.addBehavior('toggleButtonBehavior', toggleButtonBehavior) testHelper.addBehavior('toolbarButtonBehavior', toolbarButtonBehavior) +testHelper.addBehavior('treeTitleBehavior', treeTitleBehavior) testHelper.addBehavior('gridBehavior', gridBehavior) testHelper.addBehavior('dialogBehavior', dialogBehavior) diff --git a/test/specs/behaviors/testDefinitions.ts b/test/specs/behaviors/testDefinitions.ts index 8015733f50..4f73c31fdb 100644 --- a/test/specs/behaviors/testDefinitions.ts +++ b/test/specs/behaviors/testDefinitions.ts @@ -163,6 +163,48 @@ definitions.push({ }, }) +// Example: Adds attribute 'aria-expanded=true' based on the property 'open' if the component has 'hasSubtree' property. +definitions.push({ + regexp: /Adds attribute '([\w\-\w \s*]+)=([a-z]+)' based on the property '([a-z]+)' if the component has '([a-zA-Z]+)' property./g, + testMethod: (parameters: TestMethod) => { + const [ + attributeToBeAdded, + attributeExpectedValue, + propertyDependingOnFirst, + propertyDependingOnSecond, + ] = [...parameters.props] + + const property = {} + + property[propertyDependingOnFirst] = attributeExpectedValue + property[propertyDependingOnSecond] = true + const actualResult = parameters.behavior(property).attributes.root[attributeToBeAdded] + expect(testHelper.convertToBooleanIfApplicable(actualResult)).toEqual( + testHelper.convertToBooleanIfApplicable(attributeExpectedValue), + ) + + const propertyFirstPropNegate = {} + propertyFirstPropNegate[propertyDependingOnFirst] = !testHelper.convertToBooleanIfApplicable( + attributeExpectedValue, + ) + propertyFirstPropNegate[propertyDependingOnSecond] = true + const actualResultFirstPropertyNegate = parameters.behavior(propertyFirstPropNegate).attributes + .root[attributeToBeAdded] + expect(testHelper.convertToBooleanIfApplicable(actualResultFirstPropertyNegate)).toEqual( + !testHelper.convertToBooleanIfApplicable(attributeExpectedValue), + ) + + const propertyFirstPropUndefined = {} + propertyFirstPropUndefined[propertyDependingOnFirst] = true + propertyFirstPropUndefined[propertyDependingOnSecond] = undefined + const actualResultFirstPropertyNegateUndefined = parameters.behavior(propertyFirstPropUndefined) + .attributes.root[attributeToBeAdded] + expect( + testHelper.convertToBooleanIfApplicable(actualResultFirstPropertyNegateUndefined), + ).toEqual(undefined) + }, +}) + // Example: Adds role='button' if element type is other than 'button'. definitions.push({ regexp: /Adds role='([a-z]+)' if element type is other than '[a-z]+'\.+/g, diff --git a/test/specs/behaviors/testHelper.tsx b/test/specs/behaviors/testHelper.tsx index f79e51e7cb..8b9842f294 100644 --- a/test/specs/behaviors/testHelper.tsx +++ b/test/specs/behaviors/testHelper.tsx @@ -115,9 +115,12 @@ export class TestHelper { return importedBehavior } - public convertToBooleanIfApplicable(stringToConvert: string) { - if (stringToConvert === 'true' || stringToConvert === 'false') { - return Boolean(stringToConvert) + public convertToBooleanIfApplicable(stringToConvert: any) { + if (stringToConvert === 'true') { + return true + } + if (stringToConvert === 'false') { + return false } return stringToConvert } diff --git a/test/specs/components/Tree/Tree-test.tsx b/test/specs/components/Tree/Tree-test.tsx new file mode 100644 index 0000000000..0999e2b17f --- /dev/null +++ b/test/specs/components/Tree/Tree-test.tsx @@ -0,0 +1,7 @@ +import { isConformant } from 'test/specs/commonTests' + +import Tree from 'src/components/Tree/Tree' + +describe('Tree', () => { + isConformant(Tree) +})