TreeView
A tree view is a component based on nodes that are shown in a hierarchical structure.
install | yarn add @clayui/core |
---|---|
version | 3.123.0 |
Beta3.123.0View in LexiconCHANGELOG
Example
Introduction
The TreeView component is designed to display data using a hierarchical structure. It was built with performance in mind, which influences API design and feature implementations.
This component follows a slightly different thought from our low-level and high-level philosophy, we consider this to be a middle-level component that allows you to build the two in one, allowing flexibility and adding features that you wouldn't have in a low-level. It's also easy to move from low-level to high-level quickly and the APIs are more consistent.
Content
As TreeView is middle-level, it allows you to build static or dynamic content if you need to consume data from some service.
It uses a very common technique in React.js components called render props whose value is a function that will receive some data. This is essential for the component to be data agnostic. It allows more flexibility with the names of the properties to be rendered and avoids creating APIs to change those properties.
<Mouse render={(mouse) => <Cat mouse={mouse} />} />
Another benefit is using children
as a render prop which allows you to quickly adapt the static component to dynamic just by changing the syntax.
<Mouse>{(mouse) => <Cat mouse={mouse} />}</Mouse>
The main components of the TreeView are <TreeView.Item />
, <TreeView.Group />
and <TreeView.ItemStack />
which abstract all the necessary markup complexity and allow the granular composition to build different styles of a TreeView. For example: File Explorer, Users, Page Navigation, or Page Structure.
Static
<TreeView>
<TreeView.Item>
<TreeView.ItemStack>
<Sticker displayType="primary" shape="user-icon" size="sm">
NS
</Sticker>
nisi quis eleifend
</TreeView.ItemStack>
<TreeView.Group>
<TreeView.Item key="Victor Valle">
<TreeView.ItemStack>
<Sticker displayType="primary" shape="user-icon" size="sm">
FP
</Sticker>
fusce ut placerat
</TreeView.ItemStack>
<TreeView.Group>
<TreeView.Item key="susana-vázquez">
<Sticker
displayType="primary"
shape="user-icon"
size="sm"
>
UT
</Sticker>
ultrices dui sapien
</TreeView.Item>
</TreeView.Group>
</TreeView.Item>
<TreeView.Item key="emily-young">
<Sticker displayType="primary" shape="user-icon" size="sm">
MC
</Sticker>
maecenas pharetra convallis
</TreeView.Item>
</TreeView.Group>
</TreeView.Item>
</TreeView>
Dynamic
The TreeView and Group components when used with dynamic items are populated from hierarchical data. The component consumes the data using the items
property.
For the component to be dynamic it is necessary to convert children
to render prop, which must be a function that will receive the item
of the current iterator.
function Example() {
const items = [
{
children: [
{
children: [{name: 'ultrices dui sapien'}],
name: 'fusce ut placerat',
},
{name: 'maecenas pharetra convallis'},
],
name: 'nisi quis eleifend',
},
];
return (
<TreeView defaultItems={items} nestedKey="children">
{(item) => (
<TreeView.Item key={item.name}>
<TreeView.ItemStack>{item.name}</TreeView.ItemStack>
{item.children && (
<TreeView.Group items={item.children}>
{(item) => (
<TreeView.Item key={item.name}>
{item.name}
</TreeView.Item>
)}
</TreeView.Group>
)}
</TreeView.Item>
)}
</TreeView>
);
}
Asynchronous Item
When a tree is very large, loading items (nodes) asynchronously is preferred to decrease the initial payload and memory space. TreeView provides two ways to load asynchronous data:
- Load the children of an item when clicking on the item
- Load paginated data from an item
Load the children of an item
The TreeView doesn't know when an item is asynchronous, so the developer must specify whether the item is asynchronous or not. The onLoadMore
property is called every time the item is a leaf node of the tree and depending on the method's return value it will behave differently.
- When returning
void
,null
orundefined
the TreeView will do nothing. - When returning the
item
will add to the tree.
When adding a new asynchronous item to the tree, the onItemsChange
method is respectively called to update the tree with a new value if the items
prop is controlled.
onLoadMore
method, only the suppression is done and an error is thrown on the console.<TreeView
onLoadMore={async (item) => {
return await fetch(`example.com/tree/item?parent_id=${item.id}`);
}}
>
{...}
</TreeView>
Load paginated data from an item
The onLoadMore
API can also be used to load paginated data for a specific item. The signature just needs to be followed so the TreeView can know what to do.
<TreeView
onLoadMore={async (item, cursor) => {
const result = await fetch(cursor ?? `example.com/tree/item?parent_id=${item.id}`);
return {
cursor: result.next,
items: result.data,
};
}}
>
{...}
</TreeView>
The cursor
helps to store the data that will be used to identify which will be the next request, this can be an offset
, cursor
, id
the URL or any other data that represents the item cursor, when there is no more data just sets the cursor to null
. The TreeView also exposes a public API that can invoke loadMore
and find out if there is a cursor for a specific item.
<TreeView {...}>
{(item, selection, expand, load) => (
<TreeView.Item>
<TreeView.ItemStack>{item.name}</TreeView.ItemStack>
<TreeView.Group items={item.children}>
{(item) => (
<TreeView.Item>{item.name}</TreeView.Item>
)}
</TreeView.Group>
{expand.has(item.id) && load.has(item.id) !== null && (
<Button
borderless
displayType="secondary"
onClick={() => load.loadMore(item.id, item)}
>
Load more results
</Button>
)}
</TreeView.Item>
)}
</TreeView>
Item
Item is very flexible and can behave in different ways to simplify usage for different use cases depending on the data.
Item also allows defining the unique key
of the component across the tree, which is also used to tell which items are expanded and selected.
Key
In a dynamic content, if the data has the property id
it is used as key
, otherwise, a key
is generated for each item at the time of rendering.
In static content, it is also possible to define the key
to be used in the selection and expanding, if not defined it is also generated.
{
(item) => <TreeView.Item key={item.name}>{item.name}</TreeView.Item>;
}
<TreeView.Item key="Drive">Drive</TreeView.Item>;
String
<TreeView.Item>Drive</TreeView.Item>
Single level
<TreeView.Item>
<Checkbox />
<Icon symbol="folder" />
Drive
</TreeView.Item>
Nested
Creating a nested item is necessary to declare it inside the <TreeView.Item>
to differentiate the nested items from the current item, it is necessary to wrap the item elements in a <TreeView.ItemStack />
. To declare the items of an item, declare the same components nested inside a <TreeView.Group />
component.
<TreeView.Item>
<TreeView.ItemStack>
<Checkbox />
<Icon symbol="folder" />
Drive
</TreeView.ItemStack>
<TreeView.Group>
<TreeView.Item disabled={true}>
<Checkbox />
<Icon symbol="folder" />
Documents
</TreeView.Item>
</TreeView.Group>
</TreeView.Item>
Actions
Actions are added using the actions
property on each item. The component abstracts some of the complexities of markup and class usage to make composing components easier without having to add explicitly classes. This works great for Clay's Button
and DropDownWithItems
components, other components should consider adding the class component-action
explicitly.
<TreeView>
<TreeView.Item
actions={
<>
<Button displayType={null} monospaced>
<Icon symbol="times" />
</Button>
<DropDownWithItems
items={[{label: 'One'}, {label: 'Two'}, {label: 'Three'}]}
trigger={
<Button displayType={null} monospaced>
<Icon symbol="ellipsis-v" />
</Button>
}
/>
</>
}
>
<Icon symbol="folder" />
Folder
</TreeView.Item>
</TreeView>
Disabled
An item can be disabled by setting the disabled
prop in the <TreeView.ItemStack />
and <TreeView.Item />
components. By default, the Expander
and Checkbox
are also disabled, and any other clickable elements other than these need to be set to disabled in the composition.
<TreeView.Item>
<TreeView.ItemStack disabled={true}>
<Checkbox />
<Icon symbol="folder" />
Drive
</TreeView.ItemStack>
<TreeView.Group>
<TreeView.Item disabled={true}>
<Checkbox />
<Icon symbol="folder" />
Documents
</TreeView.Item>
</TreeView.Group>
</TreeView.Item>
Expander
The expansion of an item in the TreeView is controlled internally. It's also possible to control the state and set an initial value with the keys of the items that are initially expanded.
The TreeView exposes the expandedKeys
property and the onExpandedChange
callback to control the state of expanded items. The expandedKeys
property must be a collection of values using ia Set()
.
function Example() {
const [expandedKeys, setExpandedKeys] = useState(
new Set(['1', '2', '3'])
);
return (
<TreeView
expandedKeys={expandedKeys}
onExpandedChange={setExpandedKeys}
>
{...}
</TreeView>
);
}
Custom expander
Customizing the expander is possible using the expanderIcons
property. Changing the icons is applied the entire tree, and it's not possible to change them exclusively for a single item.
<TreeView
expanderIcons={{
close: <Icon symbol="hr" />,
open: <Icon symbol="plus" />,
}}
>
{...}
</TreeView>
Disabled
The Expander
is automatically disabled when the item is disabled but you can optionally control its state.
<TreeView.Item>
<TreeView.ItemStack disabled={true} expanderDisabled={false}>
<Checkbox />
<Icon symbol="folder" />
Drive
</TreeView.ItemStack>
<TreeView.Group>
<TreeView.Item disabled={true}>
<Checkbox />
<Icon symbol="folder" />
Documents
</TreeView.Item>
</TreeView.Group>
</TreeView.Item>
The expanderDisabled
property is only available in the <TreeView.ItemStack />
component where the Expander
is visible and has children.
Manual Expand
Some more advanced use cases want to change the behavior of expanding the item when the user interacts with the item differently, e.g single click selects, double click expands the item.
The expand
method is available via render props only when the content is dynamic, the same as selection, expose two methods, toggle
and has
.
<TreeView>
{(item, selection, expand) => (
<TreeView.Item
onClick={(event) => {
clearTimeout(clickTimerRef.current);
// Ignores the component's default behavior, clicking the
// item expands the item if it has any child items.
event.preventDefault();
switch (event.detail) {
case 1:
clickTimerRef.current = setTimeout(() => {
if (!selection.has(item.id)) {
selection.toggle(item.id);
}
}, 200);
break;
case 2:
expand.toggle(item.id);
default:
break;
}
}}
>
Item
</TreeView.Item>
)}
</TreeView>
Selection
The selection allows the user to select one or more items in the TreeView, there are three main interactions that can be configured and are defined using the selectionMode
prop.
single
select only one item.multiple
select multiple items.multiple-recursive
selects multiple items and the item's children recursively. When all children are selected, the parent will be marked as selected, otherwise it will be marked as intermediate.
The selection can be configured and composed in different ways depending on each use case. Setting the selectionMode and using a <Checkbox />
in the item will respect the configuration of the selection. It is also possible to configure the selection manually to customize what will trigger the selection or modify the look of the selected state. For more information check the manual selection section.
Selection can be controlled and uncontrolled, this means you can keep the state of selectedKeys
at the implementation level or let the TreeView keep the state controlled internally.
const [selectedKeys, setSelectionChange] = useState(new Set());
return (
<TreeView
onSelectionChange={(keys) => setSelectionChange(keys)}
selectedKeys={selectedKeys}
>
...
</TreeView>
);
Rendering the TreeView with pre-selected items will cause their parent items to be expanded so that the selected item is visible on the first render. In recursive multiple selection, the parents items will marked as intermediate.
Single Selection
The single selection state works independently of adding the <Checkbox />
and this state is set by default.
Multiple Selection
The multi-selection in TreeView is a achieved by composing with the <Checkbox />
component that must be added explicitly in the item rendering.
<TreeView.Item>
<Checkbox />
Drive
</TreeView.Item>
<TreeView.Item>
<TreeView.ItemStack>
<Checkbox />
Drive
</TreeView.ItemStack>
<TreeView.Group>
{...}
</TreeView.Group>
</TreeView.Item>
It isn't necessary to add the onChange
event or the checked
property. The TreeView adds the methods to the component under the covers. This allows you to just add the Checkbox in any order you want.
Multi-selection is also controlled in the same way as for the expander. It uses the key
of the item to select it. The selectedKeys
property and the onSelectionChange
event are used for this purpose.
function Example() {
const [selectedKeys, setSelectionChange] = useState(
new Set(['1', '2', '3'])
);
return (
<TreeView
onSelectionChange={setSelectionChange}
selectedKeys={selectedKeys}
selectionMode="multiple"
>
{...}
</TreeView>
);
}
Recursive Multiple Selection
By default, when selecting an item with nested items, its children are recursively selected.
There are some limitations: for static content the recursive selection only works if the item is expanded, (i.e visible). For dynamic content, it works independently.
Manual Selection
Manual selection is the possibility to trigger the selection without using the <Checkbox />
or visually changing the state of the selection types, such as the single selection state.
The selection
method is available via render props when the content is dynamic, it exposes two main methods, toggle
and has
that allow you to customize who will trigger and check if the item is selected.
<TreeView>
{(item, selection) => (
<TreeView.Item
active={selection.has(item.id)}
onClick={() => selection.toggle(item.id)}
>
Drive
</TreeView.Item>
)}
</TreeView>
The default behavior of clicking the item is to expand and load the item asynchronously if this is not needed when the user selects an item you can prevent this default behavior in the same way you would prevent the default behavior of the browser.
<TreeView>
{(item) => (
<TreeView.Item
onClick={(event) => {
event.preventDefault();
// You can do anything after that, for example in a Modal,
// close and select.
onClose(item);
}}
>
Drive
</TreeView.Item>
)}
</TreeView>
Drag and Drop
The drag and drop implementation handles items internally and works only for TreeView with dynamic content because it depends on the items
property and must be enabled using the dragAndDrop
property.
The TreeView handles items
immutably but follows a partial tree immutability implementation that is more optimized to do at runtime, To access these changes and be able to save in some service, you must control the state using the items
property and the onItemsChange
event.
function Example() {
const [items, setItems] = useState([]);
return (
<TreeView
dragAndDrop
items={items}
onItemsChange={setItems}
>
{...}
</TreeView>
);
}
The component allows you to add rules in Drag and Drop like disabling an item to be draggable and dropable, for this you need to set the draggable
property of the component to TreeView.Item
or TreeView.ItemStack
.
<TreeView dragAndDrop>
{(item) => (
<TreeView.Item>
<TreeView.ItemStack draggable={false}>
{item.name}
</TreeView.ItemStack>
<TreeView.Item.Group items={item.children}>
{(item) => (
<TreeView.Item draggable={false}>{item.name}</TreeView.Item>
)}
</TreeView.Item.Group>
</TreeView.Item>
)}
</TreeView>
In some cases it is necessary to check if the item can be dropped in another item, for example a TreeView from a file explorer, does not allow moving a file into another file. You need to use onItemMove
to decide if the item can be droppable on a specific item.
<TreeView
dragAndDrop
onItemMove={(item, parentItem) => {
// your logic here
// Returning false does not allow the item to be dropped in the parentItem.
return false;
}}
>
{...}
</TreeView>
Shortcuts
The TreeView implements shortcuts and manages the focus. Some shortcuts and focus trigger some actions like renaming, removing or asynchronous loading if any.
onLoadMore
method will be called as described in the Asynchronous Item section.Rename Item
The user can press the shortcut key R
or F2
to rename whatever property. In that case, feel free to open a Modal or any other user interface element that allows the user to change the item. For this to work, the onRenameItem
method must be passed as a property to the component and must be an asynchronous method.
The onRenameItem
property receives the item
object corresponding to the current item that the user wants to rename and the method needs to return a new immutable object with the new data.
<TreeView
dragAndDrop
onRenameItem={async (item) => {
return await openRenameModal(item);
}}
>
{...}
</TreeView>
Remove Item
If the user presses the Backspace
or Delete
key the TreeView removes the item from the items
structure. In which case onItemsChange
is called with the tree is updated.
Performance
The TreeView component was designed with performance in mind. Internally, the component handles everything without the developer having to worry about making implementation adjustments, but in some scenarios, it may be necessary for the developer to follow good practices to avoid a performance degradation.
This section helps with some good practices that help the TreeView remain responsive.
Selection hydration
Selection hydration is the ability for the TreeView to expand the parent items of the selected items when the component performs the first render.
This implies some performance problems because the component only knows about the selectedKeys
and the items
, and will have to traverse the entire tree to find the selected items and the path to the parent item to expand it.
This can lead to a performance problem on the first render if you have too many items or too many selected items. It's possible to mitigate this on the first render by setting the selectionHydrationMode
prop: it supports two rendering phases, render-first
and hydrate-first
, both have trade-offs that depend on the number of items being rendered:
render-first
will render first and then hydrate. It doesn't block the initial rendering but it is possible to see the items being expanded.hydrate-first
will hydrate first and then render. This blocks rendering first until it traverses the tree, when rendered the items are already expanded.