diff --git a/fakemui/fakemui/data-display/TreeView.tsx b/fakemui/fakemui/data-display/TreeView.tsx new file mode 100644 index 000000000..8f2dbbf81 --- /dev/null +++ b/fakemui/fakemui/data-display/TreeView.tsx @@ -0,0 +1,282 @@ +import React, { useState, useCallback, useMemo } from 'react' +import clsx from 'clsx' +import styles from '../styles/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 +} + +interface TreeItemProps { + node: TreeNode + level: number + expanded: Set + selected: Set + 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 = () => ( + + + +) + +const DefaultCollapseIcon = () => ( + + + +) + +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 ? ( + {endIcon} + ) : ( + + ) + } + + return ( + + {isExpanded + ? collapseIcon || + : expandIcon || } + + ) + } + + return ( +
  • +
    + {renderIcon()} + {node.icon && {node.icon}} + {node.label} +
    + {hasChildren && isExpanded && ( +
      + {node.children!.map((child) => ( + + ))} +
    + )} +
  • + ) +} + +/** + * 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' } + * ]} + * ] + * console.log(id)} /> + * ``` + */ +export function TreeView({ + data, + defaultExpanded = [], + expanded: controlledExpanded, + defaultSelected = [], + selected: controlledSelected, + multiSelect = false, + onSelect, + onSelectionChange, + onToggle, + expandIcon, + collapseIcon, + endIcon, + className, + dense = false, +}: TreeViewProps) { + const [internalExpanded, setInternalExpanded] = useState>( + new Set(defaultExpanded) + ) + const [internalSelected, setInternalSelected] = useState>( + 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 + + 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 ( +
      + {data.map((node) => ( + + ))} +
    + ) +} + +export default TreeView diff --git a/fakemui/styles/TreeView.module.scss b/fakemui/styles/TreeView.module.scss new file mode 100644 index 000000000..6993c33c9 --- /dev/null +++ b/fakemui/styles/TreeView.module.scss @@ -0,0 +1,138 @@ +@use '../styles/variables' as *; + +.treeView { + margin: 0; + padding: 0; + list-style: none; +} + +.treeItem { + list-style: none; +} + +.treeItemContent { + display: flex; + align-items: center; + padding: 8px 8px; + cursor: pointer; + border-radius: var(--radius-sm, 4px); + transition: background-color 0.15s ease; + user-select: none; + min-height: 40px; + + &:hover:not(.disabled) { + background-color: var(--action-hover, rgba(0, 0, 0, 0.04)); + } + + &:focus { + outline: 2px solid var(--primary-main, #1976d2); + outline-offset: -2px; + } + + &.selected { + background-color: var(--primary-selected, rgba(25, 118, 210, 0.12)); + + &:hover { + background-color: var(--primary-selected-hover, rgba(25, 118, 210, 0.2)); + } + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &.dense { + padding: 4px 8px; + min-height: 32px; + } +} + +.treeItemIcon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-right: 4px; + flex-shrink: 0; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + cursor: pointer; + border-radius: 50%; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--action-hover, rgba(0, 0, 0, 0.04)); + } + + svg { + transition: transform 0.2s ease; + } +} + +.treeItemIconPlaceholder { + width: 24px; + height: 24px; + margin-right: 4px; + flex-shrink: 0; +} + +.treeItemNodeIcon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-right: 8px; + flex-shrink: 0; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); +} + +.treeItemLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + line-height: 1.43; + color: var(--text-primary, rgba(0, 0, 0, 0.87)); +} + +.treeItemChildren { + margin: 0; + padding: 0; + list-style: none; +} + +// Dark mode support +:global(.dark) { + .treeItemContent { + &:hover:not(.disabled) { + background-color: var(--action-hover-dark, rgba(255, 255, 255, 0.08)); + } + + &.selected { + background-color: var(--primary-selected-dark, rgba(144, 202, 249, 0.16)); + + &:hover { + background-color: var(--primary-selected-hover-dark, rgba(144, 202, 249, 0.24)); + } + } + } + + .treeItemIcon { + color: var(--text-secondary-dark, rgba(255, 255, 255, 0.7)); + + &:hover { + background-color: var(--action-hover-dark, rgba(255, 255, 255, 0.08)); + } + } + + .treeItemNodeIcon { + color: var(--text-secondary-dark, rgba(255, 255, 255, 0.7)); + } + + .treeItemLabel { + color: var(--text-primary-dark, #fff); + } +}