diff --git a/apps/website/src/content/docs/tree.mdx b/apps/website/src/content/docs/tree.mdx index 505dfd5ff9f..63ed02c9e26 100644 --- a/apps/website/src/content/docs/tree.mdx +++ b/apps/website/src/content/docs/tree.mdx @@ -1,27 +1,160 @@ --- title: Tree -description: A tree provides a list of data. +description: A tree provides a hierarchical lists of data with nested expandable levels. thumbnail: Tree ---

{frontmatter.description}

- - -A tree can be used to organize data in an application specific way, or it can be used to sort, filter, group, or search data as the user deems appropriate. Each line of data level may begin with an eye icon for toggling its visibility. In this context, this icon is called specialty checkbox. +The `Tree` component can be used to organize data in an application specific way, or it can be used to sort, filter, group, or search data as the user deems appropriate. + +## Usage + +To initialize the tree component, the following props are required: + +- `data`: An array of the custom data that represents a tree node. +- `getNode`: A function that maps your `data` entry to `NodeData` that has all information about the node state. Here is where one can control the state of expanded, selected and disabled nodes. The function must be memoized. +- `nodeRenderer`: A function to render the tree node using `NodeData`. We recommend this function to return the `TreeNode` component. This function must be memoized. + +### Subnode + +The tree supports hierarchial data structures where each node can have subnodes, which can be expanded or collapsed. Subnodes allow handling nested data up to any desired depth. + +Each object in the `data` array can include an array of sub-item objects. This array can be named according to the user's preference (e.g., `subItems` in the example below) to represent its children, enabling nested structures. + +```jsx +const treeData = React.useMemo( + () => [ + { + id: 'Node-1', + label: 'Node 1', + subItems: [ + { id: 'Node-1-1', label: 'Node 1.1' }, + { + id: 'Node-1-2', + label: 'Node 1.2', + subItems: [ + { id: 'Node-1-2-1', label: 'Node 1.2.1' }, + { id: 'Node-1-2-2', label: 'Node 1.2.2' }, + ], + }, + ], + }, + { + id: 'Node-2', + label: 'Node 2', + subItems: [{ id: 'Node-2-1', label: 'Node 2.1' }], + }, + ], + [], +); +``` + +The `getNode` function then needs to map the user `data` to a `NodeData`. The properties relevant to sub-nodes include: + +- `subNodes`: array of child `data` nodes. Can be obtained from the `data`'s `subItems`. +- `hasSubNodes`: indicates whether the node has subnodes which determines whether the nodes should be expandable. + +```jsx {5, 6} +const getNode = React.useCallback( + (node) => { + return { + /*…*/ + subNodes: node.subItems, + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes], +); +``` + +### Expansion + +A state variable can be used to track each node and its expansion state. The `onExpand` function in each `TreeNode` can be used to update the node's expansion state accordingly. + +```jsx +const onExpand = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((prev) => ({ + ...prev, + [nodeId]: isExpanded, + })); +}, []); +``` + +The `isExpanded` flag which indicates whether the node is expanded to display its subnode(s) should be passed into the `getNode` function for each node to be updated its expansion state correctly. + +```jsx {5} +const getNode = React.useCallback( + (node) => { + return { + /*…*/ + isExpanded: expandedNodes[node.id], + }; + }, + [expandedNodes], +); +``` + + + + + +#### Expander customization + +The `expander` prop in the `TreeNode` component allows for customization of the node expanders. We recommend using the `TreeNodeExpander` component with this prop to customize the appearance and behavior of the expanders. If `hasSubNodes` is false, the expanders will not be shown. + + + + + +### Selection + +The tree allows end users to select one or multiple nodes within its structure. This feature is useful for actions on specific nodes, such as editing, deleting or viewing details. + +Similar to node expansion, a state variable can be used to track the currently selected node. This state can be updated via the `onSelect` callback which is triggered whenever a user selects a node. The `isSelected` flag must be set in the `getNode` function to correctly update each node's selection state. -## Size + + + + +### Size -There are 2 different sizes available. The default size should suffice for most cases. When a smaller version of the tree is needed, use `size="small"`. +There are two different sizes available. The default size should suffice for most cases. When a smaller version of the tree is needed, use `size="small"`. +### Visibility checkbox + +Each data level line may begin with an eye icon to toggle visibility. In this context, we suggest using the [Checkbox](/docs/checkbox) component with the `variant` set to `"eyeballs"` and passing it into the `checkbox` prop of the `TreeNode`. + + + + + +### Virtualization + +For trees with a large number of nodes, enabling virtualization can improve performance. To enable virtualization, the `enableVirtualization` property of the tree component can be set to `true`. + + + + + ## Props +### TreeNode + + + +### TreeNodeExpander + + + +### Tree + diff --git a/examples/Tree.customizeExpander.css b/examples/Tree.customizeExpander.css new file mode 100644 index 00000000000..174530fae42 --- /dev/null +++ b/examples/Tree.customizeExpander.css @@ -0,0 +1,3 @@ +.demo-tree { + width: min(100%, 260px); +} diff --git a/examples/Tree.customizeExpander.jsx b/examples/Tree.customizeExpander.jsx new file mode 100644 index 00000000000..6eaf9216f7f --- /dev/null +++ b/examples/Tree.customizeExpander.jsx @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { Tree, TreeNode, TreeNodeExpander } from '@itwin/itwinui-react'; + +export default () => { + const [expandedNodes, setExpandedNodes] = React.useState({}); + const disabledNodes = { 'Node-0': true, 'Node-2': true }; + + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); + }, []); + + const generateItem = React.useCallback( + (index, parentNode = '', depth = 0) => { + const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; + return { + id: `Node-${keyValue}`, + label: `Node ${keyValue}`, + sublabel: `Sublabel for Node ${keyValue}`, + subItems: + depth < 10 + ? Array(Math.round(index % 5)) + .fill(null) + .map((_, index) => generateItem(index, keyValue, depth + 1)) + : [], + }; + }, + [], + ); + + const data = React.useMemo( + () => + Array(3) + .fill(null) + .map((_, index) => generateItem(index)), + [generateItem], + ); + + const getNode = React.useCallback( + (node) => { + return { + subNodes: node.subItems, + nodeId: node.id, + node: node, + isExpanded: expandedNodes[node.id], + isDisabled: Object.keys(disabledNodes).some( + (id) => node.id === id || node.id.startsWith(`${id}-`), + ), + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes], + ); + + return ( + ( + { + onNodeExpanded(node.id, !rest.isExpanded); + e.stopPropagation(); + }} + /> + } + {...rest} + /> + ), + [onNodeExpanded], + )} + /> + ); +}; diff --git a/examples/Tree.expansion.css b/examples/Tree.expansion.css new file mode 100644 index 00000000000..174530fae42 --- /dev/null +++ b/examples/Tree.expansion.css @@ -0,0 +1,3 @@ +.demo-tree { + width: min(100%, 260px); +} diff --git a/examples/Tree.expansion.jsx b/examples/Tree.expansion.jsx new file mode 100644 index 00000000000..58d1a3113d6 --- /dev/null +++ b/examples/Tree.expansion.jsx @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { Tree, TreeNode } from '@itwin/itwinui-react'; + +export default () => { + const [expandedNodes, setExpandedNodes] = React.useState({}); + + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); + }, []); + + const generateItem = React.useCallback( + (index, parentNode = '', depth = 0) => { + const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; + return { + id: `Node-${keyValue}`, + label: `Node ${keyValue}`, + sublabel: `Sublabel for Node ${keyValue}`, + subItems: + depth < 10 + ? Array(Math.round(index % 5)) + .fill(null) + .map((_, index) => generateItem(index, keyValue, depth + 1)) + : [], + }; + }, + [], + ); + + const data = React.useMemo( + () => + Array(3) + .fill(null) + .map((_, index) => generateItem(index)), + [generateItem], + ); + + const getNode = React.useCallback( + (node) => { + return { + subNodes: node.subItems, + nodeId: node.id, + node: node, + isExpanded: expandedNodes[node.id], + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes], + ); + + return ( + ( + + ), + [onNodeExpanded], + )} + /> + ); +}; diff --git a/examples/Tree.main.jsx b/examples/Tree.main.jsx index e0a7d77e442..c8709796a6f 100644 --- a/examples/Tree.main.jsx +++ b/examples/Tree.main.jsx @@ -4,48 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import { Tree, TreeNode } from '@itwin/itwinui-react'; -import { SvgPlaceholder } from '@itwin/itwinui-icons-react'; export default () => { - const [expandedNodes, setExpandedNodes] = React.useState({ - 'Node-2': true, - 'Node-2-1': true, - 'Node-3': true, - }); + const [expandedNodes, setExpandedNodes] = React.useState({}); + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { - if (isExpanded) { - setExpandedNodes((oldExpanded) => ({ ...oldExpanded, [nodeId]: true })); - } else { - setExpandedNodes((oldExpanded) => ({ - ...oldExpanded, - [nodeId]: false, - })); - } + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); }, []); - const generateItem = React.useCallback( - (index, parentNode = '', depth = 0) => { - const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; - return { - id: `Node-${keyValue}`, - label: `Node ${keyValue}`, - sublabel: `Sublabel for Node ${keyValue}`, - subItems: - depth < 10 - ? Array(Math.round(index % 5)) - .fill(null) - .map((_, index) => generateItem(index, keyValue, depth + 1)) - : [], - }; - }, - [], - ); const data = React.useMemo( - () => - Array(3) - .fill(null) - .map((_, index) => generateItem(index)), - [generateItem], + () => [ + { + id: 'Node-0', + label: 'Node 0', + }, + { + id: 'Node-1', + label: 'Node 1', + subItems: [{ id: 'Subnode-1', label: 'Subnode 1' }], + }, + { + id: 'Node-2', + label: 'Node 2', + subItems: [{ id: 'Subnode-2', label: 'Subnode 2' }], + }, + ], + [], ); const getNode = React.useCallback( @@ -55,7 +42,7 @@ export default () => { nodeId: node.id, node: node, isExpanded: expandedNodes[node.id], - hasSubNodes: node.subItems.length > 0, + hasSubNodes: node.subItems?.length > 0, }; }, [expandedNodes], @@ -68,13 +55,7 @@ export default () => { getNode={getNode} nodeRenderer={React.useCallback( ({ node, ...rest }) => ( - } - {...rest} - /> + ), [onNodeExpanded], )} diff --git a/examples/Tree.selection.css b/examples/Tree.selection.css new file mode 100644 index 00000000000..174530fae42 --- /dev/null +++ b/examples/Tree.selection.css @@ -0,0 +1,3 @@ +.demo-tree { + width: min(100%, 260px); +} diff --git a/examples/Tree.selection.jsx b/examples/Tree.selection.jsx new file mode 100644 index 00000000000..52d498b0938 --- /dev/null +++ b/examples/Tree.selection.jsx @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import React, { useCallback, useState } from 'react'; +import { Tree, TreeNode } from '@itwin/itwinui-react'; + +export default () => { + const [expandedNodes, setExpandedNodes] = React.useState({}); + const [selectedNodes, setSelectedNodes] = useState({}); + + const onSelectedNodeChange = useCallback((nodeId, isSelected) => { + setSelectedNodes((oldSelected) => ({ + ...oldSelected, + [nodeId]: isSelected, + })); + }, []); + + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); + }, []); + + const generateItem = React.useCallback( + (index, parentNode = '', depth = 0) => { + const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; + return { + id: `Node-${keyValue}`, + label: `Node ${keyValue}`, + sublabel: `Sublabel for Node ${keyValue}`, + subItems: + depth < 10 + ? Array(Math.round(index % 5)) + .fill(null) + .map((_, index) => generateItem(index, keyValue, depth + 1)) + : [], + }; + }, + [], + ); + + const data = React.useMemo( + () => + Array(3) + .fill(null) + .map((_, index) => generateItem(index)), + [generateItem], + ); + + const getNode = React.useCallback( + (node) => { + return { + subNodes: node.subItems, + nodeId: node.id, + node: node, + isExpanded: expandedNodes[node.id], + isSelected: selectedNodes[node.id], + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes, selectedNodes], + ); + + return ( + ( + + ), + [onNodeExpanded, onSelectedNodeChange], + )} + /> + ); +}; diff --git a/examples/Tree.small.jsx b/examples/Tree.small.jsx index bc04224088a..751126ba080 100644 --- a/examples/Tree.small.jsx +++ b/examples/Tree.small.jsx @@ -4,23 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import { Tree, TreeNode } from '@itwin/itwinui-react'; -import { SvgPlaceholder } from '@itwin/itwinui-icons-react'; export default () => { - const [expandedNodes, setExpandedNodes] = React.useState({ - 'Node-2': true, - 'Node-2-1': true, - 'Node-3': true, - }); + const [expandedNodes, setExpandedNodes] = React.useState({}); const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { - if (isExpanded) { - setExpandedNodes((oldExpanded) => ({ ...oldExpanded, [nodeId]: true })); - } else { - setExpandedNodes((oldExpanded) => ({ - ...oldExpanded, - [nodeId]: false, - })); - } + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); }, []); const generateItem = React.useCallback( (index, parentNode = '', depth = 0) => { @@ -68,12 +59,7 @@ export default () => { getNode={getNode} nodeRenderer={React.useCallback( ({ node, ...rest }) => ( - } - {...rest} - /> + ), [onNodeExpanded], )} diff --git a/examples/Tree.virtualization.css b/examples/Tree.virtualization.css new file mode 100644 index 00000000000..c2da6751534 --- /dev/null +++ b/examples/Tree.virtualization.css @@ -0,0 +1,5 @@ +.demo-tree { + width: min(100%, 260px); + height: 100%; + overflow: auto; +} diff --git a/examples/Tree.virtualization.jsx b/examples/Tree.virtualization.jsx new file mode 100644 index 00000000000..f1df3d82158 --- /dev/null +++ b/examples/Tree.virtualization.jsx @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { Tree, TreeNode } from '@itwin/itwinui-react'; + +export default () => { + const [expandedNodes, setExpandedNodes] = React.useState({}); + + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); + }, []); + + const generateItem = React.useCallback( + (index, parentNode = '', depth = 0) => { + const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; + return { + id: `Node-${keyValue}`, + label: `Node ${keyValue}`, + sublabel: `Sublabel for Node ${keyValue}`, + subItems: + depth < 10 + ? Array(Math.round(index % 5)) + .fill(null) + .map((_, index) => generateItem(index, keyValue, depth + 1)) + : [], + }; + }, + [], + ); + + const data = React.useMemo( + () => + Array(10000) + .fill(null) + .map((_, index) => generateItem(index)), + [generateItem], + ); + + const getNode = React.useCallback( + (node) => { + return { + subNodes: node.subItems, + nodeId: node.id, + node: node, + isExpanded: expandedNodes[node.id], + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes], + ); + + return ( + ( + + ), + [onNodeExpanded], + )} + /> + ); +}; diff --git a/examples/Tree.visibilityCheckbox.css b/examples/Tree.visibilityCheckbox.css new file mode 100644 index 00000000000..174530fae42 --- /dev/null +++ b/examples/Tree.visibilityCheckbox.css @@ -0,0 +1,3 @@ +.demo-tree { + width: min(100%, 260px); +} diff --git a/examples/Tree.visibilityCheckbox.jsx b/examples/Tree.visibilityCheckbox.jsx new file mode 100644 index 00000000000..c27a82f6770 --- /dev/null +++ b/examples/Tree.visibilityCheckbox.jsx @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { Checkbox, Tree, TreeNode } from '@itwin/itwinui-react'; + +export default () => { + const [expandedNodes, setExpandedNodes] = React.useState({}); + + const onNodeExpanded = React.useCallback((nodeId, isExpanded) => { + setExpandedNodes((oldExpanded) => ({ + ...oldExpanded, + [nodeId]: isExpanded, + })); + }, []); + + const generateItem = React.useCallback( + (index, parentNode = '', depth = 0) => { + const keyValue = parentNode ? `${parentNode}-${index}` : `${index}`; + return { + id: `Node-${keyValue}`, + label: `Node ${keyValue}`, + sublabel: `Sublabel for Node ${keyValue}`, + subItems: + depth < 10 + ? Array(Math.round(index % 5)) + .fill(null) + .map((_, index) => generateItem(index, keyValue, depth + 1)) + : [], + }; + }, + [], + ); + + const data = React.useMemo( + () => + Array(3) + .fill(null) + .map((_, index) => generateItem(index)), + [generateItem], + ); + + const getNode = React.useCallback( + (node) => { + return { + subNodes: node.subItems, + nodeId: node.id, + node: node, + isExpanded: expandedNodes[node.id], + hasSubNodes: node.subItems.length > 0, + }; + }, + [expandedNodes], + ); + + return ( + ( + + } + {...rest} + /> + ), + [onNodeExpanded], + )} + /> + ); +}; diff --git a/examples/index.tsx b/examples/index.tsx index 9da43c04374..95031d8b2c6 100644 --- a/examples/index.tsx +++ b/examples/index.tsx @@ -1453,10 +1453,36 @@ import { default as TreeMainExampleRaw } from './Tree.main'; const TreeMainExample = withThemeProvider(TreeMainExampleRaw); export { TreeMainExample }; +import { default as TreeExpansionExampleRaw } from './Tree.expansion'; +const TreeExpansionExample = withThemeProvider(TreeExpansionExampleRaw); +export { TreeExpansionExample }; + +import { default as TreeVisibilityCheckboxExampleRaw } from './Tree.visibilityCheckbox'; +const TreeVisibilityCheckboxExample = withThemeProvider( + TreeVisibilityCheckboxExampleRaw, +); +export { TreeVisibilityCheckboxExample }; + +import { default as TreeSelectionExampleRaw } from './Tree.selection'; +const TreeSelectionExample = withThemeProvider(TreeSelectionExampleRaw); +export { TreeSelectionExample }; + import { default as TreeSmallExampleRaw } from './Tree.small'; const TreeSmallExample = withThemeProvider(TreeSmallExampleRaw); export { TreeSmallExample }; +import { default as TreeVirtualizationExampleRaw } from './Tree.virtualization'; +const TreeVirtualizationExample = withThemeProvider( + TreeVirtualizationExampleRaw, +); +export { TreeVirtualizationExample }; + +import { default as TreeCustomizeExpanderExampleRaw } from './Tree.customizeExpander'; +const TreeCustomizeExpanderExample = withThemeProvider( + TreeCustomizeExpanderExampleRaw, +); +export { TreeCustomizeExpanderExample }; + // ---------------------------------------------------------------------------- import { default as VisuallyHiddenIconExampleRaw } from './VisuallyHidden.icon';