mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 14:25:02 +00:00
288 lines
7.3 KiB
TypeScript
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
|