diff --git a/packages/components/docs/sass.md b/packages/components/docs/sass.md index f4b1dd11a081..a9821421f6d4 100644 --- a/packages/components/docs/sass.md +++ b/packages/components/docs/sass.md @@ -433,6 +433,8 @@ - [❌⚠️tooltip--definition--legacy [mixin]](#tooltip--definition--legacy-mixin) - [❌⚠️tooltip--icon--legacy [mixin]](#tooltip--icon--legacy-mixin) - [❌tooltip [mixin]](#tooltip-mixin) +- [treeview](#treeview) + - [❌treeview [mixin]](#treeview-mixin) - [ui-shell](#ui-shell) - [❌carbon-content [mixin]](#carbon-content-mixin) - [✅mini-units [function]](#mini-units-function) @@ -1868,6 +1870,7 @@ $prefix: 'bx'; - [tooltip--definition--legacy [mixin]](#tooltip--definition--legacy-mixin) - [tooltip--icon--legacy [mixin]](#tooltip--icon--legacy-mixin) - [tooltip [mixin]](#tooltip-mixin) + - [treeview [mixin]](#treeview-mixin) - [carbon-content [mixin]](#carbon-content-mixin) - [carbon-header-panel [mixin]](#carbon-header-panel-mixin) - [carbon-header [mixin]](#carbon-header-mixin) @@ -3725,6 +3728,7 @@ $spacing-03: $carbon--spacing-03; - [select [mixin]](#select-mixin) - [tabs [mixin]](#tabs-mixin) - [tooltip [mixin]](#tooltip-mixin) + - [treeview [mixin]](#treeview-mixin) - [carbon-switcher [mixin]](#carbon-switcher-mixin) ### ✅spacing-04 [variable] @@ -3773,6 +3777,7 @@ $spacing-05: $carbon--spacing-05; - [search [mixin]](#search-mixin) - [select [mixin]](#select-mixin) - [tabs [mixin]](#tabs-mixin) + - [treeview [mixin]](#treeview-mixin) - [carbon-switcher [mixin]](#carbon-switcher-mixin) ### ✅spacing-06 [variable] @@ -3813,6 +3818,7 @@ $spacing-07: $carbon--spacing-07; - [modal [mixin]](#modal-mixin) - [search [mixin]](#search-mixin) - [select [mixin]](#select-mixin) + - [treeview [mixin]](#treeview-mixin) - [carbon-switcher [mixin]](#carbon-switcher-mixin) ### ✅spacing-08 [variable] @@ -3833,6 +3839,7 @@ $spacing-08: $carbon--spacing-08; - [carbon--theme [mixin]](#carbon--theme-mixin) - [dropdown [mixin]](#dropdown-mixin) - [search [mixin]](#search-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅spacing-09 [variable] @@ -6545,6 +6552,7 @@ $interactive-01: if( - [pseudo-underline [mixin]](#pseudo-underline-mixin) - [progress-indicator [mixin]](#progress-indicator-mixin) - [tooltip--definition--legacy [mixin]](#tooltip--definition--legacy-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅interactive-02 [variable] @@ -6701,6 +6709,7 @@ $ui-01: if( - [slider [mixin]](#slider-mixin) - [tabs [mixin]](#tabs-mixin) - [tile [mixin]](#tile-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅ui-02 [variable] @@ -6906,6 +6915,7 @@ $text-01: if( - [text-input [mixin]](#text-input-mixin) - [tile [mixin]](#tile-mixin) - [tooltip--definition--legacy [mixin]](#tooltip--definition--legacy-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅text-02 [variable] @@ -6947,6 +6957,7 @@ $text-02: if( - [toggle [mixin]](#toggle-mixin) - [toolbar [mixin]](#toolbar-mixin) - [tooltip [mixin]](#tooltip-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅text-03 [variable] @@ -7088,6 +7099,7 @@ $icon-01: if( - [overflow-menu [mixin]](#overflow-menu-mixin) - [radio-button [mixin]](#radio-button-mixin) - [search [mixin]](#search-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅icon-02 [variable] @@ -7924,6 +7936,7 @@ $hover-ui: if( - [tabs [mixin]](#tabs-mixin) - [tile [mixin]](#tile-mixin) - [time-picker [mixin]](#time-picker-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅active-ui [variable] @@ -7984,6 +7997,7 @@ $selected-ui: if( - [dropdown [mixin]](#dropdown-mixin) - [listbox [mixin]](#listbox-mixin) - [search [mixin]](#search-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅selected-light-ui [variable] @@ -8036,6 +8050,7 @@ $hover-selected-ui: if( - [data-table-core [mixin]](#data-table-core-mixin) - [data-table-expandable [mixin]](#data-table-expandable-mixin) - [tabs [mixin]](#tabs-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅inverse-hover-ui [variable] @@ -8195,6 +8210,7 @@ $disabled-01: if( - [tags [mixin]](#tags-mixin) - [text-input [mixin]](#text-input-mixin) - [toggle [mixin]](#toggle-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅disabled-02 [variable] @@ -8243,6 +8259,7 @@ $disabled-02: if( - [text-input [mixin]](#text-input-mixin) - [time-picker [mixin]](#time-picker-mixin) - [toggle [mixin]](#toggle-mixin) + - [treeview [mixin]](#treeview-mixin) ### ✅disabled-03 [variable] @@ -25962,6 +25979,165 @@ Tooltip styles - [spacing-03 [variable]](#spacing-03-variable) - [interactive-04 [variable]](#interactive-04-variable) +## treeview + +### ❌treeview [mixin] + +Treeview styles + +
+Source code + +```scss +@mixin treeview() { + .#{$prefix}--tree { + overflow: hidden; + + .#{$prefix}--tree-node { + padding-left: $spacing-05; + color: $text-02; + background-color: $ui-01; + + &:focus { + outline: none; + } + } + + .#{$prefix}--tree-node:focus > .#{$prefix}--tree-node__label { + @include focus-outline('outline'); + } + + .#{$prefix}--tree-node--disabled { + color: $disabled-02; + background-color: $disabled-01; + pointer-events: none; + } + + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__label:hover { + background-color: $disabled-01; + } + + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-parent-node__toggle-icon, + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__icon { + fill: $disabled-02; + } + + .#{$prefix}--tree-node--disabled + .#{$prefix}--tree-parent-node__toggle-icon:hover { + cursor: default; + } + + .#{$prefix}--tree-node__label { + display: flex; + flex: 1; + align-items: center; + min-height: rem(32px); + + &:hover { + background-color: $hover-ui; + } + } + + .#{$prefix}--tree-leaf-node { + display: flex; + padding-left: $spacing-08; + } + + .#{$prefix}--tree-leaf-node.#{$prefix}--tree-node--with-icon { + padding-left: $spacing-07; + } + + .#{$prefix}--tree-node__label__details { + display: flex; + align-items: center; + } + + .#{$prefix}--tree-node--with-icon .#{$prefix}--tree-parent-node__toggle { + margin-right: 0; + } + + .#{$prefix}--tree-parent-node__toggle { + width: rem(16px); + height: rem(16px); + margin-right: $spacing-03; + padding: 0; + background-color: transparent; + border: 0; + + &:hover { + cursor: pointer; + } + + &:focus { + outline: none; + } + } + + .#{$prefix}--tree-parent-node__toggle-icon { + transform: rotate(-90deg); + transition: all $duration--fast-02 motion(standard, productive); + fill: $icon-01; + } + + .#{$prefix}--tree-parent-node__toggle-icon--expanded { + transform: rotate(0); + } + + .#{$prefix}--tree-node__icon { + margin-right: $spacing-03; + fill: $icon-01; + } + + .#{$prefix}--tree-node--selected > .#{$prefix}--tree-node__label { + color: $text-01; + background-color: $selected-ui; + + &:hover { + background-color: $hover-selected-ui; + } + } + + .#{$prefix}--tree-node--active > .#{$prefix}--tree-node__label { + position: relative; + + &::before { + position: absolute; + top: 0; + left: 0; + width: rem(4px); + height: 100%; + background-color: $interactive-01; + content: ''; + } + } + } + + .#{$prefix}--tree--compact .#{$prefix}--tree-node__label { + min-height: rem(24px); + } +} +``` + +
+ +- **Group**: [treeview](#treeview) +- **Requires**: + - [prefix [variable]](#prefix-variable) + - [spacing-05 [variable]](#spacing-05-variable) + - [text-02 [variable]](#text-02-variable) + - [ui-01 [variable]](#ui-01-variable) + - [disabled-02 [variable]](#disabled-02-variable) + - [disabled-01 [variable]](#disabled-01-variable) + - [hover-ui [variable]](#hover-ui-variable) + - [spacing-08 [variable]](#spacing-08-variable) + - [spacing-07 [variable]](#spacing-07-variable) + - [spacing-03 [variable]](#spacing-03-variable) + - [icon-01 [variable]](#icon-01-variable) + - [text-01 [variable]](#text-01-variable) + - [selected-ui [variable]](#selected-ui-variable) + - [hover-selected-ui [variable]](#hover-selected-ui-variable) + - [interactive-01 [variable]](#interactive-01-variable) + ## ui-shell ### ❌carbon-content [mixin] diff --git a/packages/components/src/components/treeview/_treeview.scss b/packages/components/src/components/treeview/_treeview.scss new file mode 100644 index 000000000000..604ea373079d --- /dev/null +++ b/packages/components/src/components/treeview/_treeview.scss @@ -0,0 +1,147 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +//----------------------------- +// Treeview +//----------------------------- + +@import '../../globals/scss/vars'; +@import '../../globals/scss/vendor/@carbon/elements/scss/import-once/import-once'; +@import '../../globals/scss/helper-mixins'; +@import '../../globals/scss/typography'; + +/// Treeview styles +/// @access private +/// @group treeview +@mixin treeview { + .#{$prefix}--tree { + overflow: hidden; + + .#{$prefix}--tree-node { + padding-left: $spacing-05; + color: $text-02; + background-color: $ui-01; + + &:focus { + outline: none; + } + } + + .#{$prefix}--tree-node:focus > .#{$prefix}--tree-node__label { + @include focus-outline('outline'); + } + + .#{$prefix}--tree-node--disabled { + color: $disabled-02; + background-color: $disabled-01; + pointer-events: none; + } + + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__label:hover { + background-color: $disabled-01; + } + + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-parent-node__toggle-icon, + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__icon { + fill: $disabled-02; + } + + .#{$prefix}--tree-node--disabled + .#{$prefix}--tree-parent-node__toggle-icon:hover { + cursor: default; + } + + .#{$prefix}--tree-node__label { + display: flex; + flex: 1; + align-items: center; + min-height: rem(32px); + + &:hover { + background-color: $hover-ui; + } + } + + .#{$prefix}--tree-leaf-node { + display: flex; + padding-left: $spacing-08; + } + + .#{$prefix}--tree-leaf-node.#{$prefix}--tree-node--with-icon { + padding-left: $spacing-07; + } + + .#{$prefix}--tree-node__label__details { + display: flex; + align-items: center; + } + + .#{$prefix}--tree-node--with-icon .#{$prefix}--tree-parent-node__toggle { + margin-right: 0; + } + + .#{$prefix}--tree-parent-node__toggle { + margin-right: $spacing-03; + padding: 0; + border: 0; + + &:hover { + cursor: pointer; + } + + &:focus { + outline: none; + } + } + + .#{$prefix}--tree-parent-node__toggle-icon { + transform: rotate(-90deg); + transition: all $duration--fast-02 motion(standard, productive); + fill: $icon-01; + } + + .#{$prefix}--tree-parent-node__toggle-icon--expanded { + transform: rotate(0); + } + + .#{$prefix}--tree-node__icon { + margin-right: $spacing-03; + fill: $icon-01; + } + + .#{$prefix}--tree-node--selected > .#{$prefix}--tree-node__label { + color: $text-01; + background-color: $selected-ui; + + &:hover { + background-color: $hover-selected-ui; + } + } + + .#{$prefix}--tree-node--active > .#{$prefix}--tree-node__label { + position: relative; + + &::before { + position: absolute; + top: 0; + left: 0; + width: rem(4px); + height: 100%; + background-color: $interactive-01; + content: ''; + } + } + } + + .#{$prefix}--tree--compact .#{$prefix}--tree-node__label { + min-height: rem(24px); + } +} + +@include exports('treeview') { + @include treeview; +} diff --git a/packages/react/.storybook/styles.scss b/packages/react/.storybook/styles.scss index d74e50684871..a671ae619860 100644 --- a/packages/react/.storybook/styles.scss +++ b/packages/react/.storybook/styles.scss @@ -60,6 +60,7 @@ $prefix: 'bx'; @import '~carbon-components/src/components/tooltip/tooltip'; @import '~carbon-components/src/components/tabs/tabs'; @import '~carbon-components/src/components/tag/tag'; +@import '~carbon-components/src/components/treeview/treeview'; @import '~carbon-components/src/components/pagination/pagination'; @import '~carbon-components/src/components/pagination/unstable_pagination'; @import '~carbon-components/src/components/accordion/accordion'; diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 210fdbe8ade2..6f7d787cf918 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7099,5 +7099,230 @@ Map { }, }, }, + "unstable_TreeView" => Object { + "TreeNode": Object { + "propTypes": Object { + "active": Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "depth": Object { + "type": "number", + }, + "disabled": Object { + "type": "bool", + }, + "isExpanded": Object { + "type": "bool", + }, + "label": Object { + "type": "node", + }, + "onNodeFocusEvent": Object { + "type": "func", + }, + "onSelect": Object { + "type": "func", + }, + "onToggle": Object { + "type": "func", + }, + "onTreeSelect": Object { + "type": "func", + }, + "renderIcon": Object { + "args": Array [ + Array [ + Object { + "type": "func", + }, + Object { + "type": "object", + }, + ], + ], + "type": "oneOfType", + }, + "selected": Object { + "args": Array [ + Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + ], + "type": "arrayOf", + }, + "value": Object { + "type": "string", + }, + }, + }, + "propTypes": Object { + "active": Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "hideLabel": Object { + "type": "bool", + }, + "label": Object { + "isRequired": true, + "type": "string", + }, + "multiselect": Object { + "type": "bool", + }, + "onSelect": Object { + "type": "func", + }, + "selected": Object { + "args": Array [ + Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + ], + "type": "arrayOf", + }, + "size": Object { + "args": Array [ + Array [ + "default", + "compact", + ], + ], + "type": "oneOf", + }, + }, + }, + "unstable_TreeNode" => Object { + "propTypes": Object { + "active": Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "depth": Object { + "type": "number", + }, + "disabled": Object { + "type": "bool", + }, + "isExpanded": Object { + "type": "bool", + }, + "label": Object { + "type": "node", + }, + "onNodeFocusEvent": Object { + "type": "func", + }, + "onSelect": Object { + "type": "func", + }, + "onToggle": Object { + "type": "func", + }, + "onTreeSelect": Object { + "type": "func", + }, + "renderIcon": Object { + "args": Array [ + Array [ + Object { + "type": "func", + }, + Object { + "type": "object", + }, + ], + ], + "type": "oneOfType", + }, + "selected": Object { + "args": Array [ + Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + ], + "type": "arrayOf", + }, + "value": Object { + "type": "string", + }, + }, + }, } `; diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index 180afdd51a7b..9bcfa0f298f1 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -194,6 +194,8 @@ describe('Carbon Components React', () => { "UnorderedList", "unstable_PageSelector", "unstable_Pagination", + "unstable_TreeNode", + "unstable_TreeView", ] `); }); diff --git a/packages/react/src/components/TreeView/TreeNode.js b/packages/react/src/components/TreeView/TreeNode.js new file mode 100644 index 000000000000..bd5d82aec208 --- /dev/null +++ b/packages/react/src/components/TreeView/TreeNode.js @@ -0,0 +1,294 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { CaretDown16 } from '@carbon/icons-react'; +import classNames from 'classnames'; +import { settings } from 'carbon-components'; +import { keys, match, matches } from '../../internal/keyboard'; +import uniqueId from '../../tools/uniqueId'; + +const { prefix } = settings; + +export default function TreeNode({ + active, + children, + className, + depth, + disabled, + isExpanded, + label, + onNodeFocusEvent, + onSelect: onNodeSelect, + onToggle, + onTreeSelect, + renderIcon: Icon, + selected, + value, + ...rest +}) { + const { current: id } = useRef(rest.id || uniqueId()); + const [expanded, setExpanded] = useState(isExpanded); + const currentNode = useRef(null); + const currentNodeLabel = useRef(null); + const nodesWithProps = React.Children.map(children, (node) => { + if (React.isValidElement(node)) { + return React.cloneElement(node, { + active, + depth: depth + 1, + disabled, + onTreeSelect, + selected, + tabIndex: (!node.props.disabled && -1) || null, + }); + } + }); + const isActive = active === id; + const isSelected = selected.includes(id); + const treeNodeClasses = classNames(className, `${prefix}--tree-node`, { + [`${prefix}--tree-node--active`]: isActive, + [`${prefix}--tree-node--disabled`]: disabled, + [`${prefix}--tree-node--selected`]: isSelected, + [`${prefix}--tree-node--with-icon`]: Icon, + [`${prefix}--tree-leaf-node`]: !children, + [`${prefix}--tree-parent-node`]: children, + }); + const toggleClasses = classNames(`${prefix}--tree-parent-node__toggle-icon`, { + [`${prefix}--tree-parent-node__toggle-icon--expanded`]: expanded, + }); + function handleToggleClick(event) { + if (onToggle) { + onToggle(event, { id, isExpanded: !expanded, label, value }); + } + setExpanded(!expanded); + } + function handleClick(event) { + event.stopPropagation(); + if (!disabled) { + if (onTreeSelect) { + onTreeSelect(event, { id, label, value }); + } + if (onNodeSelect) { + onNodeSelect(event, { id, label, value }); + } + if (rest.onClick) { + rest.onClick(event); + } + } + } + function handleKeyDown(event) { + if (matches(event, [keys.ArrowLeft, keys.ArrowRight, keys.Enter])) { + event.stopPropagation(); + } + if (match(event, keys.ArrowLeft)) { + const findParentTreeNode = (node) => { + if (node.classList.contains(`${prefix}--tree-parent-node`)) { + return node; + } + if (node.classList.contains(`${prefix}--tree`)) { + return null; + } + return findParentTreeNode(node.parentNode); + }; + if (children && expanded) { + onToggle(event, { id, isExpanded: false, label, value }); + setExpanded(false); + } else { + /** + * When focus is on a leaf node or a closed parent node, move focus to + * its parent node (unless its depth is level 1) + */ + findParentTreeNode(currentNode.current.parentNode)?.focus(); + } + } + if (children && match(event, keys.ArrowRight)) { + if (expanded) { + /** + * When focus is on an expanded parent node, move focus to the first + * child node + */ + currentNode.current.lastChild.firstChild.focus(); + } else { + onToggle(event, { id, isExpanded: true, label, value }); + setExpanded(true); + } + } + if (matches(event, [keys.Enter, keys.Space])) { + event.preventDefault(); + handleClick(event); + } + if (rest.onKeyDown) { + rest.onKeyDown(event); + } + } + function handleFocusEvent(event) { + if (event.type === 'blur' && rest.onBlur) { + rest.onBlur(event); + } + if (event.type === 'focus' && rest.onFocus) { + rest.onFocus(event); + } + onNodeFocusEvent && onNodeFocusEvent(event); + } + + useEffect(() => { + /** + * Negative margin shifts node to align with the left side boundary of the + * tree + * Dynamically calculate padding to recreate tree node indentation + * - parent nodes have (depth + 1rem) left padding + * - leaf nodes have (depth + 2.5rem) left padding without icons (because + * of expando icon + spacing) + * - leaf nodes have (depth + 2rem) left padding with icons (because of + * reduced spacing between the expando icon and the node icon + label) + */ + const calcOffset = () => { + // parent node + if (children) { + return depth + 1; + } + // leaf node with icon + if (Icon) { + return depth + 2; + } + // leaf node without icon + return depth + 2.5; + }; + + if (currentNodeLabel.current) { + currentNodeLabel.current.style.marginLeft = `-${calcOffset()}rem`; + currentNodeLabel.current.style.paddingLeft = `${calcOffset()}rem`; + } + + // sync props and state + setExpanded(expanded); + }, [children, depth, expanded, Icon]); + + const treeNodeProps = { + ...rest, + ['aria-selected']: disabled ? null : isSelected, + ['aria-disabled']: disabled, + className: treeNodeClasses, + id, + onBlur: handleFocusEvent, + onClick: handleClick, + onFocus: handleFocusEvent, + onKeyDown: handleKeyDown, + ref: currentNode, + role: 'treeitem', + }; + + if (!children) { + return ( +
  • +
    + {Icon && } + {label} +
    +
  • + ); + } + return ( +
  • +
    + {/* https://github.com/carbon-design-system/carbon/pull/6008#issuecomment-675738670 */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + + + + + {Icon && } + {label} + +
    + {expanded && ( + + )} +
  • + ); +} + +TreeNode.propTypes = { + /** + * The value of the active node in the tree + */ + active: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * Specify the children of the TreeNode + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the TreeNode + */ + className: PropTypes.string, + + /** + * TreeNode depth to determine spacing, automatically calculated by default + */ + depth: PropTypes.number, + + /** + * Specify if the TreeNode is disabled + */ + disabled: PropTypes.bool, + + /** + * Specify if the TreeNode is expanded (only applicable to parent nodes) + */ + isExpanded: PropTypes.bool, + + /** + * Rendered label for the TreeNode + */ + label: PropTypes.node, + + /** + * Callback function for when the node receives or loses focus + */ + onNodeFocusEvent: PropTypes.func, + + /** + * Callback function for when the node is selected + */ + onSelect: PropTypes.func, + + /** + * Callback function for when a parent node is expanded or collapsed + */ + onToggle: PropTypes.func, + + /** + * Callback function for when any node in the tree is selected + */ + onTreeSelect: PropTypes.func, + + /** + * Optional prop to allow each node to have an associated icon. + * Can be a React component class + */ + renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Array containing all selected node IDs in the tree + */ + selected: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + + /** + * Specify the value of the TreeNode + */ + value: PropTypes.string, +}; diff --git a/packages/react/src/components/TreeView/TreeView-story.js b/packages/react/src/components/TreeView/TreeView-story.js new file mode 100644 index 000000000000..306ff9d2f21a --- /dev/null +++ b/packages/react/src/components/TreeView/TreeView-story.js @@ -0,0 +1,285 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { Document16, Folder16 } from '@carbon/icons-react'; +import { action } from '@storybook/addon-actions'; +import { + boolean, + object, + select, + text, + withKnobs, +} from '@storybook/addon-knobs'; +import { InlineNotification } from '../Notification'; +import TreeView, { TreeNode } from '../TreeView'; + +const sizes = { + default: 'default', + compact: 'compact', +}; +const props = () => ({ + active: text('Active node ID (active)', '5'), + hideLabel: boolean('Visible label (hideLabel)', false), + label: text('Label (label)', 'Tree view'), + multiselect: boolean( + 'Allow selection of multiple tree nodes (multiselect)', + false + ), + onSelect: action('onSelect (TreeView onSelect)'), + selected: object('Array of selected node IDs (selected)', ['5']), + size: select('Tree size (sizes)', sizes, 'default'), +}); +const nodes = [ + { + id: '1', + value: 'Artificial intelligence', + label: Artificial intelligence, + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '2', + value: 'Blockchain', + label: 'Blockchain', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '3', + value: 'Business automation', + label: 'Business automation', + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '3-1', + value: 'Business process automation', + label: 'Business process automation', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '3-2', + value: 'Business process mapping', + label: 'Business process mapping', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + ], + }, + { + id: '4', + value: 'Business operations', + label: 'Business operations', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '5', + value: 'Cloud computing', + label: 'Cloud computing', + isExpanded: true, + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '5-1', + value: 'Containers', + label: 'Containers', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '5-2', + value: 'Databases', + label: 'Databases', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '5-3', + value: 'DevOps', + label: 'DevOps', + isExpanded: true, + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '5-4', + value: 'Solutions', + label: 'Solutions', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '5-5', + value: 'Case studies', + label: 'Case studies', + isExpanded: true, + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '5-6', + value: 'Resources', + label: 'Resources', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + ], + }, + ], + }, + ], + }, + { + id: '6', + value: 'Data & Analytics', + label: 'Data & Analytics', + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '6-1', + value: 'Big data', + label: 'Big data', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '6-2', + value: 'Business intelligence', + label: 'Business intelligence', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + ], + }, + { + id: '7', + value: 'IT infrastructure', + label: 'IT infrastructure', + isExpanded: true, + disabled: true, + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '7-1', + value: 'Data storage', + label: 'Data storage', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '7-2', + value: 'Enterprise servers', + label: 'Enterprise servers', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '8', + value: 'Hybrid cloud infrastructure', + label: 'Hybrid cloud infrastructure', + isExpanded: true, + renderIcon: Folder16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + children: [ + { + id: '8-1', + value: 'Insights', + label: 'Insights', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + { + id: '8-2', + value: 'Benefits', + label: 'Benefits', + renderIcon: Document16, + onSelect: action('onSelect (TreeNode onSelect)'), + onToggle: action('onToggle'), + }, + ], + }, + ], + }, +]; + +function renderTree({ nodes, withIcons = false }) { + if (!nodes) { + return; + } + return nodes.map(({ children, renderIcon, ...nodeProps }) => ( + + {renderTree({ nodes: children, withIcons })} + + )); +} + +export default { + title: 'unstable_TreeView', + decorators: [withKnobs], + parameters: { component: TreeView }, +}; + +export const Default = () => ( + <> + + {renderTree({ nodes })} + +); + +Default.storyName = 'default'; +Default.parameters = { + info: { + text: ``, + }, +}; + +export const WithIcons = () => ( + <> + + {renderTree({ nodes, withIcons: true })} + +); + +WithIcons.storyName = 'with icons'; diff --git a/packages/react/src/components/TreeView/TreeView-test.js b/packages/react/src/components/TreeView/TreeView-test.js new file mode 100644 index 000000000000..fda475dc1aaf --- /dev/null +++ b/packages/react/src/components/TreeView/TreeView-test.js @@ -0,0 +1,100 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { Document16, Folder16 } from '@carbon/icons-react'; +import { settings } from 'carbon-components'; +import TreeView, { TreeNode } from './'; + +const { prefix } = settings; + +describe('TreeView', () => { + let wrapper; + let onTreeSelect; + let onNodeSelect; + + beforeEach(() => { + onTreeSelect = jest.fn(); + onNodeSelect = jest.fn(); + wrapper = mount( + + + + + + + + + + + + ); + }); + + it('should render', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('should render with icons', () => { + wrapper = mount( + + + + + + + + + + + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('should be able to hide the label', () => { + expect(wrapper.find('TreeLabel').text()).toBe('Tree view'); + wrapper.setProps({ hideLabel: true }); + expect(wrapper.find('TreeLabel').text()).toBeFalsy(); + }); + + describe('Single node selection', () => { + it('should be able to preselect a node', () => { + expect(wrapper.find(`.${prefix}--tree-node--selected`).text()).toBe('1'); + }); + + it('should handle selection at the tree level', () => { + const onTreeSelect = jest.fn(); + wrapper.setProps({ onSelect: onTreeSelect }); + wrapper.find('TreeNode[value="2"]').simulate('click'); + expect(onTreeSelect).toHaveBeenCalledTimes(1); + }); + + it('should handle selection at the node level', () => { + wrapper.find('TreeNode[value="2"]').simulate('click'); + expect(onTreeSelect).toHaveBeenCalledTimes(1); + expect(onNodeSelect).toHaveBeenCalledTimes(1); + }); + }); + + describe('Tree node expansion', () => { + it('Caret icon should not render in leaf nodes', () => { + expect(wrapper.find('ForwardRef(CaretDown16)').length).toBe(2); + }); + }); +}); diff --git a/packages/react/src/components/TreeView/TreeView.js b/packages/react/src/components/TreeView/TreeView.js new file mode 100644 index 000000000000..3d25b4bf68aa --- /dev/null +++ b/packages/react/src/components/TreeView/TreeView.js @@ -0,0 +1,222 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { settings } from 'carbon-components'; +import { keys, match, matches } from '../../internal/keyboard'; +import uniqueId from '../../tools/uniqueId'; + +const { prefix } = settings; + +export default function TreeView({ + active: prespecifiedActive, + children, + className, + hideLabel = false, + label, + multiselect, + onSelect, + selected: preselected = [], + size = 'default', + ...rest +}) { + const { current: treeId } = useRef(rest.id || uniqueId()); + const treeClasses = classNames(className, `${prefix}--tree`, { + [`${prefix}--tree--${size}`]: size !== 'default', + }); + const treeRootRef = useRef(null); + const treeWalker = useRef(treeRootRef?.current); + const [selected, setSelected] = useState(preselected); + const [active, setActive] = useState(prespecifiedActive); + function resetNodeTabIndices() { + Array.prototype.forEach.call( + treeRootRef?.current?.querySelectorAll('[tabIndex="0"]') ?? [], + (item) => { + item.tabIndex = -1; + } + ); + } + function handleTreeSelect(event, node = {}) { + const { id: nodeId } = node; + if (multiselect && (event.metaKey || event.ctrlKey)) { + if (!selected.includes(nodeId)) { + setSelected(selected.concat(nodeId)); + } else { + setSelected(selected.filter((selectedId) => selectedId !== nodeId)); + } + } else { + setSelected([nodeId]); + setActive(nodeId); + } + if (onSelect) { + onSelect(event, node); + } + } + function handleFocusEvent(event) { + if (event.type === 'blur') { + const { + relatedTarget: currentFocusedNode, + target: prevFocusedNode, + } = event; + if (treeRootRef?.current?.contains(currentFocusedNode)) { + prevFocusedNode.tabIndex = -1; + } + } + if (event.type === 'focus') { + resetNodeTabIndices(); + const { + relatedTarget: prevFocusedNode, + target: currentFocusedNode, + } = event; + if (treeRootRef?.current?.contains(prevFocusedNode)) { + prevFocusedNode.tabIndex = -1; + } + currentFocusedNode.tabIndex = 0; + } + } + let focusTarget = false; + const nodesWithProps = React.Children.map(children, (node) => { + const sharedNodeProps = { + active, + depth: 0, + onNodeFocusEvent: handleFocusEvent, + onTreeSelect: handleTreeSelect, + selected, + tabIndex: (!node.props.disabled && -1) || null, + }; + if (!focusTarget && !node.props.disabled) { + sharedNodeProps.tabIndex = 0; + focusTarget = true; + } + if (React.isValidElement(node)) { + return React.cloneElement(node, sharedNodeProps); + } + }); + + function handleKeyDown(event) { + event.stopPropagation(); + if (matches(event, [keys.ArrowUp, keys.ArrowDown])) { + event.preventDefault(); + } + treeWalker.current.currentNode = event.target; + let nextFocusNode; + if (match(event, keys.ArrowUp)) { + nextFocusNode = treeWalker.current.previousNode(); + } + if (match(event, keys.ArrowDown)) { + nextFocusNode = treeWalker.current.nextNode(); + } + if (nextFocusNode && nextFocusNode !== event.target) { + resetNodeTabIndices(); + nextFocusNode.tabIndex = 0; + nextFocusNode.focus(); + } + if (rest.onKeyDown) { + rest.onKeyDown(event); + } + } + + useEffect(() => { + treeWalker.current = + treeWalker.current ?? + document.createTreeWalker(treeRootRef?.current, NodeFilter.SHOW_ELEMENT, { + acceptNode: function (node) { + if (node.classList.contains(`${prefix}--tree-node--disabled`)) { + return NodeFilter.FILTER_REJECT; + } + if (node.matches(`li.${prefix}--tree-node`)) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }, + }); + }, []); + + useEffect(() => { + if (preselected.length) { + setSelected(preselected); + } + if (prespecifiedActive) { + setActive(prespecifiedActive); + } + }, [preselected, prespecifiedActive]); + + const labelId = `${treeId}__label`; + const TreeLabel = () => + !hideLabel && ( + + ); + return ( + <> + +
      + {nodesWithProps} +
    + + ); +} + +TreeView.propTypes = { + /** + * Mark the active node in the tree, represented by its value + */ + active: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * Specify the children of the TreeView + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the TreeView + */ + className: PropTypes.string, + + /** + * Specify whether or not the label should be hidden + */ + hideLabel: PropTypes.bool, + + /** + * Provide the label text that will be read by a screen reader + */ + label: PropTypes.string.isRequired, + + /** + * Specify the selection mode of the tree. + * If `multiselect` is `false` then only one node can be selected at a time + */ + multiselect: PropTypes.bool, + + /** + * Callback function that is called when any node is seleected + */ + onSelect: PropTypes.func, + + /** + * Array representing all selected node IDs in the tree + */ + selected: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + + /** + * Specify the size of the tree from a list of available sizes. + */ + size: PropTypes.oneOf(['default', 'compact']), +}; diff --git a/packages/react/src/components/TreeView/__snapshots__/TreeView-test.js.snap b/packages/react/src/components/TreeView/__snapshots__/TreeView-test.js.snap new file mode 100644 index 000000000000..8e908c5edf78 --- /dev/null +++ b/packages/react/src/components/TreeView/__snapshots__/TreeView-test.js.snap @@ -0,0 +1,953 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TreeView should render 1`] = ` + + + + +
      + +
    • +
      + 1 +
      +
    • +
      + +
    • +
      + 2 +
      +
    • +
      + +
    • +
      + + + + + + + + + + + 5 + +
      +
        + +
      • +
        + 5-1 +
        +
      • +
        + +
      • +
        + 5-2 +
        +
      • +
        + +
      • +
        + + + + + + + + + + + 5-3 + +
        +
          + +
        • +
          + 5-4 +
          +
        • +
          +
        +
      • +
        +
      +
    • +
      +
    +
    +`; + +exports[`TreeView should render with icons 1`] = ` + + + + +
      + +
    • +
      + + + + + + + + + 1 +
      +
    • +
      + +
    • +
      + + + + + + + + + 2 +
      +
    • +
      + +
    • +
      + + + + + + + + + + + + + + + + + + 5 + +
      +
        + +
      • +
        + + + + + + + + + 5-1 +
        +
      • +
        + +
      • +
        + + + + + + + + + 5-2 +
        +
      • +
        + +
      • +
        + + + + + + + + + + + + + + + + + + 5-3 + +
        +
          + +
        • +
          + + + + + + + + + 5-4 +
          +
        • +
          +
        +
      • +
        +
      +
    • +
      +
    +
    +`; diff --git a/packages/react/src/components/TreeView/index.js b/packages/react/src/components/TreeView/index.js new file mode 100644 index 000000000000..8ced69247630 --- /dev/null +++ b/packages/react/src/components/TreeView/index.js @@ -0,0 +1,14 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import TreeView from './TreeView'; +import TreeNode from './TreeNode'; + +TreeView.TreeNode = TreeNode; + +export { TreeNode }; +export default TreeView; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index af90da591893..cbf5ccee4e6a 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -202,3 +202,6 @@ export { PageSelector as unstable_PageSelector, Pagination as unstable_Pagination, } from './components/Pagination/experimental'; +export unstable_TreeView, { + TreeNode as unstable_TreeNode, +} from './components/TreeView';