Files
2026-03-09 22:30:41 +00:00

288 lines
7.3 KiB
TypeScript

'use client'
import React, { useState, useCallback, useMemo } from 'react'
import clsx from 'clsx'
import styles from '../../../scss/TreeView.module.scss'
export interface TreeNode {
id: string
label: string
icon?: React.ReactNode
children?: TreeNode[]
disabled?: boolean
}
export interface TreeViewProps {
/** Tree data nodes */
data: TreeNode[]
/** Initially expanded node IDs */
defaultExpanded?: string[]
/** Controlled expanded node IDs */
expanded?: string[]
/** Initially selected node IDs */
defaultSelected?: string[]
/** Controlled selected node IDs */
selected?: string[]
/** Enable multi-select */
multiSelect?: boolean
/** Callback when node is selected */
onSelect?: (nodeId: string, node: TreeNode) => void
/** Callback when selection changes (multi-select) */
onSelectionChange?: (nodeIds: string[]) => void
/** Callback when node is expanded/collapsed */
onToggle?: (nodeId: string, isExpanded: boolean) => void
/** Custom expand icon */
expandIcon?: React.ReactNode
/** Custom collapse icon */
collapseIcon?: React.ReactNode
/** Custom end icon (leaf nodes) */
endIcon?: React.ReactNode
/** Additional CSS class */
className?: string
/** Dense layout */
dense?: boolean
/** Test ID for automated testing */
testId?: string
}
interface TreeItemProps {
node: TreeNode
level: number
expanded: Set<string>
selected: Set<string>
multiSelect: boolean
onToggleExpand: (nodeId: string) => void
onSelect: (nodeId: string, node: TreeNode) => void
expandIcon?: React.ReactNode
collapseIcon?: React.ReactNode
endIcon?: React.ReactNode
dense?: boolean
}
const DefaultExpandIcon = () => (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
)
const DefaultCollapseIcon = () => (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
)
function TreeItem({
node,
level,
expanded,
selected,
multiSelect,
onToggleExpand,
onSelect,
expandIcon,
collapseIcon,
endIcon,
dense,
}: TreeItemProps) {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expanded.has(node.id)
const isSelected = selected.has(node.id)
const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (hasChildren) {
onToggleExpand(node.id)
}
},
[hasChildren, node.id, onToggleExpand]
)
const handleSelect = useCallback(
(e: React.MouseEvent) => {
if (!node.disabled) {
onSelect(node.id, node)
}
},
[node, onSelect]
)
const renderIcon = () => {
if (!hasChildren) {
return endIcon ? (
<span className={styles.treeItemIcon}>{endIcon}</span>
) : (
<span className={styles.treeItemIconPlaceholder} />
)
}
return (
<span className={styles.treeItemIcon} onClick={handleToggle}>
{isExpanded
? collapseIcon || <DefaultCollapseIcon />
: expandIcon || <DefaultExpandIcon />}
</span>
)
}
return (
<li className={styles.treeItem}>
<div
className={clsx(styles.treeItemContent, {
[styles.selected as string]: isSelected,
[styles.disabled as string]: node.disabled,
[styles.dense as string]: dense,
})}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={handleSelect}
role="treeitem"
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-disabled={node.disabled}
tabIndex={node.disabled ? -1 : 0}
>
{renderIcon()}
{node.icon && <span className={styles.treeItemNodeIcon}>{node.icon}</span>}
<span className={styles.treeItemLabel}>{node.label}</span>
</div>
{hasChildren && isExpanded && (
<ul className={styles.treeItemChildren} role="group">
{node.children!.map((child) => (
<TreeItem
key={child.id}
node={child}
level={level + 1}
expanded={expanded}
selected={selected}
multiSelect={multiSelect}
onToggleExpand={onToggleExpand}
onSelect={onSelect}
expandIcon={expandIcon}
collapseIcon={collapseIcon}
endIcon={endIcon}
dense={dense}
/>
))}
</ul>
)}
</li>
)
}
/**
* TreeView component - Hierarchical tree navigation
*
* @example
* ```tsx
* const data = [
* { id: '1', label: 'Parent', children: [
* { id: '1-1', label: 'Child 1' },
* { id: '1-2', label: 'Child 2' }
* ]}
* ]
* <TreeView data={data} onSelect={(id) => console.log(id)} />
* ```
*/
export function TreeView({
data,
defaultExpanded = [],
expanded: controlledExpanded,
defaultSelected = [],
selected: controlledSelected,
multiSelect = false,
onSelect,
onSelectionChange,
onToggle,
expandIcon,
collapseIcon,
endIcon,
className,
dense = false,
testId,
}: TreeViewProps) {
const [internalExpanded, setInternalExpanded] = useState<Set<string>>(
new Set(defaultExpanded)
)
const [internalSelected, setInternalSelected] = useState<Set<string>>(
new Set(defaultSelected)
)
const expanded = useMemo(
() => (controlledExpanded !== undefined ? new Set(controlledExpanded) : internalExpanded),
[controlledExpanded, internalExpanded]
)
const selected = useMemo(
() => (controlledSelected !== undefined ? new Set(controlledSelected) : internalSelected),
[controlledSelected, internalSelected]
)
const handleToggleExpand = useCallback(
(nodeId: string) => {
const newExpanded = new Set(expanded)
const isExpanding = !newExpanded.has(nodeId)
if (isExpanding) {
newExpanded.add(nodeId)
} else {
newExpanded.delete(nodeId)
}
if (controlledExpanded === undefined) {
setInternalExpanded(newExpanded)
}
onToggle?.(nodeId, isExpanding)
},
[expanded, controlledExpanded, onToggle]
)
const handleSelect = useCallback(
(nodeId: string, node: TreeNode) => {
let newSelected: Set<string>
if (multiSelect) {
newSelected = new Set(selected)
if (newSelected.has(nodeId)) {
newSelected.delete(nodeId)
} else {
newSelected.add(nodeId)
}
} else {
newSelected = new Set([nodeId])
}
if (controlledSelected === undefined) {
setInternalSelected(newSelected)
}
onSelect?.(nodeId, node)
onSelectionChange?.(Array.from(newSelected))
},
[selected, multiSelect, controlledSelected, onSelect, onSelectionChange]
)
return (
<ul className={clsx(styles.treeView, className)} role="tree" aria-multiselectable={multiSelect} data-testid={testId}>
{data.map((node) => (
<TreeItem
key={node.id}
node={node}
level={0}
expanded={expanded}
selected={selected}
multiSelect={multiSelect}
onToggleExpand={handleToggleExpand}
onSelect={handleSelect}
expandIcon={expandIcon}
collapseIcon={collapseIcon}
endIcon={endIcon}
dense={dense}
/>
))}
</ul>
)
}
export default TreeView