import React, { useState, useEffect, useRef } from 'react'; type Option = { value: string; label: string; children?: Option[]; }; type MultiCascaderProps = { options: Option[]; defaultValue?: string[]; value?: string[]; onChange?: (value: string[]) => void; placeholder?: string; maxHeight?: number; // 下拉框最大高度,默认300px searchable?: boolean; // 是否显示搜索框,默认false searchPlaceholder?: string; // 搜索框占位符 }; // 获取所有叶子节点的值 const getAllLeafValues = (option: Option): string[] => { if (!option.children || option.children.length === 0) { return [option.value]; } return option.children.flatMap(getAllLeafValues); }; // 检查节点是否所有子节点都被选中 const isAllChildrenChecked = (option: Option, selected: string[]): boolean => { if (!option.children || option.children.length === 0) { return selected.includes(option.value); } // 对于有子节点的节点,检查其所有叶子节点是否都被选中 const leafValues = getAllLeafValues(option); return leafValues.every(value => selected.includes(value)); }; // 检查节点是否有部分子节点被选中 const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => { if (!option.children || option.children.length === 0) { return selected.includes(option.value); } // 对于有子节点的节点,检查其叶子节点是否有部分被选中 const leafValues = getAllLeafValues(option); return leafValues.some(value => selected.includes(value)); }; // 递归过滤选项,保留匹配项及其父级 const filterOptions = (options: Option[], keyword: string): Option[] => { if (!keyword.trim()) return options; const lowerKeyword = keyword.toLowerCase(); return options.reduce((acc, option) => { // 检查当前节点是否匹配 const isCurrentMatch = option.label.toLowerCase().includes(lowerKeyword); // 递归检查子节点 const filteredChildren = option.children ? filterOptions(option.children, keyword) : []; // 如果当前节点匹配,或者有匹配的子节点,则保留 if (isCurrentMatch || filteredChildren.length > 0) { acc.push({ ...option, children: isCurrentMatch ? option.children // 如果父节点匹配,保留所有子节点 : filteredChildren.length > 0 ? filteredChildren // 如果只是子节点匹配,只保留匹配的子节点 : option.children }); } return acc; }, []); }; // 获取过滤后需要展开的所有父节点 const getExpandedKeysForFilter = (options: Option[], keyword: string): Set => { const keys = new Set(); if (!keyword.trim()) return keys; const lowerKeyword = keyword.toLowerCase(); const traverse = (opts: Option[], parentKeys: string[]) => { for (const opt of opts) { const isMatch = opt.label.toLowerCase().includes(lowerKeyword); const hasChildren = opt.children && opt.children.length > 0; if (hasChildren) { // 递归检查子节点 const childMatches = traverse(opt.children!, [...parentKeys, opt.value]); // 如果子节点有匹配,展开当前节点 if (childMatches) { keys.add(opt.value); parentKeys.forEach(k => keys.add(k)); } } // 如果当前节点匹配且有父节点,展开父节点 if (isMatch && parentKeys.length > 0) { parentKeys.forEach(k => keys.add(k)); return true; } } return false; }; traverse(options, []); return keys; }; const MultiCascader: React.FC = ({ options, defaultValue = [], value, onChange, placeholder = '请选择', maxHeight = 300, searchable = false, searchPlaceholder = '搜索...' }) => { const [visible, setVisible] = useState(false); const [selected, setSelected] = useState(value ?? defaultValue); const [expandedKeys, setExpandedKeys] = useState>(new Set()); const [searchKeyword, setSearchKeyword] = useState(''); const containerRef = useRef(null); const searchInputRef = useRef(null); // 当外部 value 变化时,同步内部状态 useEffect(() => { if (value !== undefined) { setSelected(value); } }, [value]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setVisible(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // 搜索关键词变化时自动展开匹配项 useEffect(() => { if (searchKeyword.trim()) { const keysToExpand = getExpandedKeysForFilter(options, searchKeyword); setExpandedKeys(keysToExpand); } }, [searchKeyword, options]); // 下拉框打开时聚焦搜索框 useEffect(() => { if (visible && searchable && searchInputRef.current) { setTimeout(() => searchInputRef.current?.focus(), 100); } if (!visible) { setSearchKeyword(''); // 关闭时清空搜索 } }, [visible, searchable]); // 过滤后的选项 const filteredOptions = searchable ? filterOptions(options, searchKeyword) : options; const handleItemCheck = (option: Option, checked: boolean) => { const leafValues = getAllLeafValues(option); let newSelected: string[]; if (checked) { newSelected = Array.from(new Set([...selected, ...leafValues])); } else { newSelected = selected.filter(v => !leafValues.includes(v)); } setSelected(newSelected); onChange?.(newSelected); }; const getDisplayText = () => { if (selected.length === 0) return placeholder; return `已选择 ${selected.length} 项`; }; const toggleExpand = (key: string) => { setExpandedKeys(prev => { const newSet = new Set(prev); if (newSet.has(key)) { newSet.delete(key); } else { newSet.add(key); } return newSet; }); }; const renderOption = (option: Option, level: number = 0) => { const allChecked = isAllChildrenChecked(option, selected); const someChecked = isSomeChildrenChecked(option, selected); const hasChildren = option.children && option.children.length > 0; const isExpanded = expandedKeys.has(option.value); return (
0 ? "pl-4" : ""}>
{ if (el) el.indeterminate = !allChecked && someChecked; }} onChange={e => handleItemCheck(option, e.target.checked)} id={`cascader-${option.value}`} /> {hasChildren && ( )}
{hasChildren && isExpanded && (
{option.children?.map(child => ( {renderOption(child, level + 1)} ))}
)}
); }; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setVisible(!visible); } }} onClick={() => setVisible(!visible)} > {getDisplayText()}
{visible && (
{/* 搜索框 */} {searchable && (
setSearchKeyword(e.target.value)} onClick={(e) => e.stopPropagation()} /> {searchKeyword && ( )}
)} {/* 选项列表 */}
{filteredOptions.length > 0 ? ( filteredOptions.map(option => renderOption(option)) ) : (
无匹配结果
)}
)}
); }; export default MultiCascader;