import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; export interface TreeSelectNode { id: string; title: string; label?: React.ReactNode; children?: TreeSelectNode[]; disabled?: boolean; data?: Record; } interface TreeSelectProps { data: TreeSelectNode[]; value?: string; onChange?: (value: string) => void; placeholder?: string; disabled?: boolean; allowClear?: boolean; showSearch?: boolean; className?: string; defaultExpandAll?: boolean; renderSelected?: (node: TreeSelectNode | undefined) => React.ReactNode; testId?: string; } export function TreeSelect({ data, value, onChange, placeholder, disabled, allowClear, showSearch, className, defaultExpandAll, renderSelected, testId, }: TreeSelectProps) { const [open, setOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [expandedIds, setExpandedIds] = useState>(new Set()); const { t } = useTranslation(); useEffect(() => { if (!defaultExpandAll) return; const ids = new Set(); const walk = (nodes: TreeSelectNode[]) => { for (const node of nodes) { if (node.children?.length) { ids.add(node.id); walk(node.children); } } }; walk(data); setExpandedIds(ids); }, [data, defaultExpandAll]); const selectedNode = useMemo(() => { const find = (nodes: TreeSelectNode[]): TreeSelectNode | undefined => { for (const node of nodes) { if (node.id === value && value) return node; if (node.children) { const found = find(node.children); if (found) return found; } } }; return find(data); }, [data, value]); const isLeaf = useCallback( (node: TreeSelectNode) => !node.children?.length, [], ); const handleToggle = useCallback((id: string) => { setExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const handleSelect = useCallback( (node: TreeSelectNode) => { if (node.disabled) return; if (isLeaf(node)) { onChange?.(node.id); setOpen(false); setSearchTerm(''); } else { handleToggle(node.id); } }, [isLeaf, onChange, handleToggle], ); const handleClear = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onChange?.(''); }, [onChange], ); const filterTree = useCallback( (nodes: TreeSelectNode[], term: string): TreeSelectNode[] => { if (!term) return nodes; return nodes.reduce((acc, node) => { const titleMatch = node.title .toLowerCase() .includes(term.toLowerCase()); const filteredChildren = node.children ? filterTree(node.children, term) : undefined; if (titleMatch || filteredChildren?.length) { acc.push({ ...node, children: filteredChildren ?? node.children }); } return acc; }, []); }, [], ); const filteredData = useMemo( () => filterTree(data, searchTerm), [data, searchTerm, filterTree], ); const visibleExpandedIds = useMemo(() => { if (!searchTerm) return expandedIds; const ids = new Set(); const walk = (nodes: TreeSelectNode[]) => { for (const node of nodes) { if (node.children?.length) { ids.add(node.id); walk(node.children); } } }; walk(filteredData); return ids; }, [searchTerm, expandedIds, filteredData]); const renderTree = useCallback( (nodes: TreeSelectNode[], level = 0): React.ReactNode => { return nodes.map((node) => { const leaf = isLeaf(node); const expanded = visibleExpandedIds.has(node.id); const selected = value === node.id; return (
handleSelect(node)} > {!leaf && ( <> {expanded ? ( ) : ( )} )} {node.label ?? node.title}
{!leaf && expanded && node.children && (
{renderTree(node.children, level + 1)}
)}
); }); }, [isLeaf, visibleExpandedIds, value, handleSelect], ); return ( {showSearch && (
setSearchTerm(e.target.value)} />
)}
e.stopPropagation()} > {filteredData.length > 0 ? ( renderTree(filteredData) ) : (
{t('common.noData')}
)}
); }