import React, { useMemo, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button } from '../Button/Button'; import { ContextMenu } from '../ContextMenu/ContextMenu'; import { Icon } from '../Icon/Icon'; import { IconButton } from '../IconButton/IconButton'; import { Text } from '../Typography/Text/Text'; import type { FlatTreeItem, LazyTreeItem, NestedTreeItem } from './fixtures'; import { flatTreeItems, lazyTreeItems, loadLazyTreeItemChildren, nestedTreeItems, nestedTreeItemsLarge, } from './fixtures'; import type { TreeMoveHandler } from './Tree'; import { Tree } from './Tree'; import type { TreeItemKey, TreeNode } from './useTreeState'; const meta: Meta = { title: 'UI kit/Tree', component: Tree, tags: ['autodocs'], argTypes: {}, }; export default meta; type Story = StoryObj>; const defaultArgs: Story['args'] = { label: 'File System', }; /** * Basic tree component with nested items. No selection or drag & drop enabled. * This is the simplest usage of the Tree component. */ export const Basic: Story = { args: { ...defaultArgs, items: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, }, parameters: { docs: { description: { story: 'Basic tree with nested items. No selection or drag & drop.', }, }, }, }; /** * Tree with deeply nested items (30 levels). Tests the component's ability to handle * extreme nesting depth. Each level contains a single item nested within the previous one. */ export const DeeplyNested: Story = { render: args => { const [treeItems, setTreeItems] = useState( (() => { const createDeepItem = (level: number, maxLevel: number): NestedTreeItem => { if (level > maxLevel) { return { id: level, title: `Level ${level} - Final Item`, type: 'file', children: [], }; } return { id: level, title: `Level ${level} - Directory`, type: 'directory', children: [createDeepItem(level + 1, maxLevel)], }; }; return [createDeepItem(1, 30)]; })() ); const removeItem = (items: NestedTreeItem[], idToRemove: number): NestedTreeItem[] => { return items.reduce((acc, item) => { if (item.id === idToRemove) { return acc; } if (item.children && item.children.length > 0) { return [...acc, { ...item, children: removeItem(item.children, idToRemove) }]; } return [...acc, item]; }, []); }; return ( item.id} getItemChildren={item => item.children} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} renderItem={({ item, ...rest }) => ( { console.log(`Edit item: ${item.title}`); }} /> { setTreeItems(prev => removeItem(prev, item.id)); }} /> { console.log(`Create a new sub-item: ${item.title}`); }} /> } /> )} /> ); }, args: { ...defaultArgs, }, parameters: { docs: { description: { story: 'Tree with 30 levels of nesting to test handling of extreme depth.', }, }, }, }; /** * Tree with uncontrolled selection state. The tree manages its own selection internally. * Use `defaultValue` to set initial selection and `onChange` to listen for changes. */ export const SelectionUncontrolled: Story = { args: { ...defaultArgs, items: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, defaultValue: [], onChange: selection => { console.log('Selected items:', selection); }, }, }; /** * Tree with controlled selection state. The parent component controls which items are selected. * Use `value` and `onChange` to fully control the selection state. */ export const SelectionControlled: Story = { render: args => { const [selectedKeys, setSelectedKeys] = useState([1, 3, 13]); return (
Selected items: {selectedKeys.join(', ')}
item.id} getItemChildren={item => item.children} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} value={selectedKeys} onChange={keys => { setSelectedKeys(keys); console.log('Selection changed:', keys); }} />
); }, args: { ...defaultArgs, }, }; /** * Tree with 'children' selection scope. When you select a node, all its descendants are automatically selected. * This is useful for hierarchical data where selecting a parent implies selecting all children. */ export const SelectionScopeChildren: Story = { args: { ...defaultArgs, items: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, defaultValue: [], selectionScope: 'children', onChange: selection => { console.log('Selected items:', selection); }, }, }; /** * Tree with 'parents' selection scope. When you select a node, all its ancestors are automatically selected. * This is useful when child selection implies parent selection. */ export const SelectionScopeParents: Story = { args: { ...defaultArgs, items: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, defaultValue: [], selectionScope: 'parents', onChange: selection => { console.log('Selected items:', selection); }, }, }; /** * Tree with uncontrolled expanded state. The tree manages which nodes are expanded internally. * Use `defaultExpandedValues` to set initial expanded nodes and `onExpandedChange` to listen for changes. */ export const ExpansionUncontrolled: Story = { args: { ...defaultArgs, items: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, defaultExpandedValues: [1, 13], onExpandedChange: expanded => { console.log('Expanded items:', expanded); }, }, }; /** * Tree with controlled expanded state. The parent component controls which nodes are expanded. * Use `expandedValues` and `onExpandedChange` to fully control the expansion state. * This example shows how to implement "Expand All" and "Collapse All" functionality. */ export const ExpansionControlled: Story = { render: args => { const [expandedKeys, setExpandedKeys] = useState([1, 2, 13, 14]); return (
Expanded items: {expandedKeys.join(', ')}
item.id} getItemChildren={item => item.children} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} expandedValues={expandedKeys} onExpandedChange={keys => { setExpandedKeys(keys); console.log('Expanded changed:', keys); }} />
); }, args: { ...defaultArgs, }, }; /** * Tree with custom action buttons for each item. Use the `renderItem` prop to customize * the appearance of tree items and add custom actions like edit, delete, or create. */ export const CustomActions: Story = { render: args => { const [treeItems, setTreeItems] = useState(nestedTreeItems); const removeItem = (items: NestedTreeItem[], idToRemove: number): NestedTreeItem[] => { return items.reduce((acc, item) => { if (item.id === idToRemove) { return acc; } if (item.children && item.children.length > 0) { return [...acc, { ...item, children: removeItem(item.children, idToRemove) }]; } return [...acc, item]; }, []); }; return ( item.id} getItemChildren={item => item.children} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} renderItem={({ item, ...renderProps }) => ( { console.log(`Edit item: ${item.title}`); }} > Edit { setTreeItems(prev => removeItem(prev, item.id)); }} > Delete { console.log(`Create a new sub-item: ${item.title}`); }} > Add subcategory ) : ( <> { console.log(`Edit item: ${item.title}`); }} /> { setTreeItems(prev => removeItem(prev, item.id)); }} /> { console.log(`Create a new sub-item: ${item.title}`); }} /> ) } /> )} /> ); }, args: { ...defaultArgs, }, }; /** * Tree with a large dataset (2500 items). This demonstrates the component's performance * with many nested items. The tree does not use virtualization. */ export const CustomActionsLargeDataset: Story = { render: args => { const [treeItems, setTreeItems] = useState(nestedTreeItemsLarge); const removeItem = (items: NestedTreeItem[], idToRemove: number): NestedTreeItem[] => { return items.reduce((acc, item) => { if (item.id === idToRemove) { return acc; } if (item.children && item.children.length > 0) { return [...acc, { ...item, children: removeItem(item.children, idToRemove) }]; } return [...acc, item]; }, []); }; return ( item.id} getItemChildren={item => item.children} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} renderItem={({ item, ...restProps }) => ( { console.log(`Edit item: ${item.title}`); }} > Edit { setTreeItems(prev => removeItem(prev, item.id)); }} > Delete { console.log(`Create a new sub-item: ${item.title}`); }} > Add subcategory ) : ( <> { console.log(`Edit item: ${item.title}`); }} /> { setTreeItems(prev => removeItem(prev, item.id)); }} /> { console.log(`Create a new sub-item: ${item.title}`); }} /> ) } /> )} /> ); }, args: { ...defaultArgs, }, }; /** * Tree with lazy loading in uncontrolled mode. Children are loaded on demand when a node is expanded. * Use `onLoadChildren` to load data asynchronously when needed. */ export const LazyLoadingUncontrolled: Story = { args: { ...defaultArgs, }, render: args => { return ( item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={loadLazyTreeItemChildren} /> ); }, }; /** * Tree that loads entire subtrees at once. When a node is expanded, all its descendants * are loaded recursively in a single operation. Useful when you want to load complete * branches instead of one level at a time. */ export const LazyLoadingSubtree: Story = { args: { ...defaultArgs, }, render: args => { const loadEntireSubtree = async (key: number): Promise => { const children = await loadLazyTreeItemChildren(key); const loadedChildren = await Promise.all( children.map(async child => { if (child.hasChildren) { const nestedChildren = await loadEntireSubtree(child.id); return { ...child, children: nestedChildren, }; } return child; }) ); return loadedChildren; }; return ( item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={loadEntireSubtree} /> ); }, }; /** * Tree with lazy loading in controlled mode. The parent component manages the tree state * and manually updates it when children are loaded. This gives you full control over the * loading process and tree structure. */ export const LazyLoadingControlled: Story = { args: { ...defaultArgs, }, render: args => { const [treeItems, setTreeItems] = useState(lazyTreeItems); const updateTreeWithChildren = ( items: LazyTreeItem[], parentId: number, children: LazyTreeItem[] ): LazyTreeItem[] => { return items.map(item => { if (item.id === parentId) { return { ...item, children: children, }; } else if (item.children && item.children.length > 0) { return { ...item, children: updateTreeWithChildren(item.children, parentId, children), }; } return item; }); }; return ( item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={async (key: TreeItemKey) => { const newChildren = await loadLazyTreeItemChildren(key as number); setTreeItems(oldItems => updateTreeWithChildren(oldItems, key as number, newChildren)); return newChildren; }} /> ); }, }; /** * Combines lazy loading with 'children' selection scope. When selecting a node, * all its descendants are loaded (if needed) and selected automatically. */ export const LazyLoadingWithSelection: Story = { args: { ...defaultArgs, }, render: args => { const loadEntireSubtree = async (key: number): Promise => { const children = await loadLazyTreeItemChildren(key); const loadedChildren = await Promise.all( children.map(async child => { if (child.hasChildren) { const nestedChildren = await loadEntireSubtree(child.id); return { ...child, children: nestedChildren, }; } return child; }) ); return loadedChildren; }; return ( item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={loadEntireSubtree} defaultValue={[]} selectionScope='children' onChange={selection => { console.log('Selected items:', selection); }} /> ); }, }; /** * Tree with drag & drop in uncontrolled mode. Users can reorder items by dragging them. * The tree manages its own state and notifies you of changes via `onMove`. */ export const DragAndDropUncontrolled: Story = { args: { ...defaultArgs, defaultItems: nestedTreeItems, getItemValue: item => item.id, getItemChildren: item => item.children, getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, onMove: reorderedItems => { console.log('Items reordered:', reorderedItems); }, }, }; /** * Tree with drag & drop in controlled mode. The parent component receives the new tree structure * when items are reordered and can update its state accordingly. This gives you full control * over the drag & drop behavior and allows validation before accepting changes. */ export const DragAndDropControlled: Story = { render: args => { const [treeItems, setTreeItems] = useState(nestedTreeItems); const [dragCount, setDragCount] = useState(0); const extractItems = (nodes: TreeNode[]): NestedTreeItem[] => { return nodes.map(node => { const item = node.value; if (node.children && node.children.length > 0) { return { ...item, children: extractItems(node.children), } as NestedTreeItem; } return item; }); }; const onMove: TreeMoveHandler = (_, newTree) => { const newItems = extractItems(newTree ?? []); setTreeItems(newItems); setDragCount(prev => prev + 1); }; return (
Drag operations: {dragCount}
item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} />
); }, args: { ...defaultArgs, }, }; /** * Tree with flat data structure and drag & drop. Demonstrates working with denormalized data * where items reference their parent via `parentId` instead of nesting children. * The tree automatically builds the hierarchy from flat data. */ export const DragAndDropFlatData: Story = { args: { label: 'Flat File System', defaultItems: flatTreeItems.filter(item => item.parentId === null), getItemValue: item => item.id, getItemChildren: item => flatTreeItems.filter(child => child.parentId === item.id), getItemTextLabel: item => item.title, getItemTextDescription: item => item.description, onMove: e => { console.log('Items reordered:', e); }, }, }; /** * Controlled drag & drop with flat data structure. Converts between the tree's nested structure * and your flat data format. This example shows how to maintain a flat data structure while * using the tree component's drag & drop functionality. */ export const DragAndDropFlatDataControlled: Story = { render: args => { const [treeItems, setTreeItems] = useState(flatTreeItems); const [dragCount, setDragCount] = useState(0); const extractItems = (nodes: TreeNode[], parentId: number | null = null): FlatTreeItem[] => { const result: FlatTreeItem[] = []; for (const node of nodes) { const item = { ...node.value, parentId } as FlatTreeItem; result.push(item); if (node.children && node.children.length > 0) { const childItems = extractItems(node.children, node.value.id as number); result.push(...childItems); } } return result; }; const onMove: TreeMoveHandler = (_, newTree) => { setTreeItems(extractItems(newTree ?? [])); setDragCount(prev => prev + 1); }; return (
Drag operations: {dragCount}
treeItems.filter(item => !item.parentId), [treeItems])} onMove={onMove} getItemValue={item => item.id} getItemChildren={item => treeItems.filter(child => child.parentId === item.id)} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} />
); }, args: { ...defaultArgs, }, }; /** * Combines lazy loading with drag & drop in uncontrolled mode. Children are loaded on demand * and users can reorder items. This is useful for large trees where you don't want to load * all data upfront but still need drag & drop functionality. */ export const DragAndDropWithLazyLoading: Story = { args: { ...defaultArgs, }, render: args => { return ( item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={loadLazyTreeItemChildren} onMove={(e, newItems) => { console.log('Items reordered:', e, newItems); }} /> ); }, }; /** * Combines lazy loading with drag & drop in controlled mode. Gives you full control over * both the loading process and drag & drop operations. This is the most flexible setup * for complex applications that need validation and custom behavior. */ export const DragAndDropWithLazyLoadingControlled: Story = { args: { ...defaultArgs, }, render: args => { const [treeItems, setTreeItems] = useState(lazyTreeItems); const [dragCount, setDragCount] = useState(0); const extractItems = (nodes: TreeNode[]): LazyTreeItem[] => { return nodes.map(node => { const item = node.value; if (node.children && node.children.length > 0) { return { ...item, children: extractItems(node.children), } as LazyTreeItem; } return item; }); }; const updateTreeWithChildren = ( items: LazyTreeItem[], parentId: number, children: LazyTreeItem[] ): LazyTreeItem[] => { return items.map(item => { if (item.id === parentId) { return { ...item, children: children, }; } else if (item.children && item.children.length > 0) { return { ...item, children: updateTreeWithChildren(item.children, parentId, children), }; } return item; }); }; return (
Drag operations: {dragCount}
item.id} getItemChildren={item => item.children ?? []} getItemTextLabel={item => item.title} getItemTextDescription={item => item.description} getItemHasChildren={item => item.hasChildren ?? false} onLoadChildren={async (key: TreeItemKey) => { const newChildren = await loadLazyTreeItemChildren(key as number); setTreeItems(oldItems => updateTreeWithChildren(oldItems, key as number, newChildren)); return newChildren; }} onMove={(_, newTree) => { const updatedItems = extractItems(newTree ?? []); setTreeItems(updatedItems); setDragCount(prev => prev + 1); }} />
); }, };