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 && (
+
+ {label}
+
+ );
+ return (
+ <>
+
+
+ >
+ );
+}
+
+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`] = `
+
+
+
+ Tree view
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+ 5-1
+
+
+
+
+
+
+ 5-2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5-3
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TreeView should render with icons 1`] = `
+
+
+
+ Tree view
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5-3
+
+
+
+
+
+
+
+
+
+
+`;
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';