import { Checkbox, CloseButton, Combobox, Flex, getTreeExpandedState, Input, InputProps, Popover, Radio, RenderTreeNodePayload, Stack, Text, TextInput, TreeNodeData, useTree, } from "@mantine/core" import { useDisclosure } from "@mantine/hooks" import { IconChevronUp, IconSearch, IconX } from "@tabler/icons-react" import React, { useEffect, useMemo, useState } from "react" import classes from "./VirtualTreeSelect.module.css" import { VirtualizedTree } from "./VirtualizedTree" import { buildTreeValueMap, filterTreeData_dfs, findNodesByValues, getDescendantLeafValues, getLabelsByValues, getStateFromDataByName, } from "./libs/util" interface BaseVirtualTreeSelectProps extends Omit< InputProps, "value" | "defaultValue" | "onChange" > { data: TreeNodeData[] placeholder?: string closeOnSelect?: boolean label?: React.ReactNode } interface SingleVirtualTreeSelectProps extends BaseVirtualTreeSelectProps { mode?: "single" value?: string | null onChange?: (value: string | null, node: TreeNodeData | null) => void } interface MultipleVirtualTreeSelectProps extends BaseVirtualTreeSelectProps { mode: "multiple" value?: string[] onChange?: (value: string[], nodes: TreeNodeData[]) => void } type VirtualTreeSelectProps = | SingleVirtualTreeSelectProps | MultipleVirtualTreeSelectProps const areSameValues = (left: string[], right: string[]) => left.length === right.length && left.every((value, index) => value === right[index]) export function VirtualTreeSelect(props: VirtualTreeSelectProps) { const { data, mode = "single", value, onChange, placeholder = "请选择...", closeOnSelect: closeOnSelectProp, label, ...inputProps } = props const [opened, { close, open, toggle }] = useDisclosure(false) const [searchName, setSearchName] = useState("") const closeOnSelect = closeOnSelectProp ?? mode === "single" const valueMap = useMemo(() => buildTreeValueMap(data), [data]) const controlledValues = useMemo(() => { if (mode === "multiple") { return Array.isArray(value) ? value : [] } return typeof value === "string" ? [value] : [] }, [mode, value]) const filteredTreeData = useMemo( () => filterTreeData_dfs(data, searchName), [data, searchName] ) const tree = useTree({ initialExpandedState: getTreeExpandedState(data, []), initialSelectedState: mode === "single" ? controlledValues : [], initialCheckedState: mode === "multiple" ? controlledValues : [], multiple: mode === "multiple", }) const currentValues = mode === "multiple" ? tree.checkedState : tree.selectedState const selectedLabels = useMemo( () => getLabelsByValues(valueMap, currentValues), [currentValues, valueMap] ) const displayValue = useMemo(() => { if (mode === "multiple" && selectedLabels.length > 2) { return "" } return selectedLabels.join("、") }, [mode, selectedLabels]) const shouldShowCountTag = mode === "multiple" && currentValues.length > 2 const hasValue = currentValues.length > 0 const triggerTitle = selectedLabels.join("、") || placeholder useEffect(() => { tree.initialize(data) // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]) useEffect(() => { if (mode === "multiple") { if (!areSameValues(tree.checkedState, controlledValues)) { tree.setCheckedState(controlledValues) } if (tree.selectedState.length) { tree.setSelectedState([]) } return } if (!areSameValues(tree.selectedState, controlledValues)) { tree.setSelectedState(controlledValues) } if (tree.checkedState.length) { tree.setCheckedState([]) } // `useTree()` returns a new controller object every render; depending on it // here would retrigger syncs that only need to respond to controlled values. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ controlledValues, mode, tree.checkedState, tree.selectedState, tree.setCheckedState, tree.setSelectedState, ]) useEffect(() => { if (filteredTreeData.length && searchName) { tree.setExpandedState(getStateFromDataByName(filteredTreeData)) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredTreeData, searchName, tree.setExpandedState]) const emitChange = (nextValues: string[]) => { if (mode === "multiple") { ;(onChange as MultipleVirtualTreeSelectProps["onChange"] | undefined)?.( nextValues, findNodesByValues(valueMap, nextValues) ) return } const nextValue = nextValues[0] ?? null ;(onChange as SingleVirtualTreeSelectProps["onChange"] | undefined)?.( nextValue, nextValue ? (valueMap[nextValue]?.node ?? null) : null ) } const handleSingleSelect = (nodeValue: string, hasChildren: boolean) => { if (hasChildren) { return } const nextValues = currentValues[0] === nodeValue ? [] : [nodeValue] tree.setSelectedState(nextValues) emitChange(nextValues) if (nextValues.length && closeOnSelect) { close() } } const handleMultipleToggle = (nodeValue: string) => { const leafValues = getDescendantLeafValues(data, nodeValue) if (!leafValues.length) { return } const nextValues = tree.isNodeChecked(nodeValue) ? tree.checkedState.filter((item) => !leafValues.includes(item)) : Array.from(new Set([...tree.checkedState, ...leafValues])) tree.setCheckedState(nextValues) emitChange(nextValues) } const handleNodeSelect = (nodeValue: string, hasChildren: boolean) => { if (mode === "multiple") { handleMultipleToggle(nodeValue) return } handleSingleSelect(nodeValue, hasChildren) } const handleClear = () => { if (mode === "multiple") { tree.setCheckedState([]) emitChange([]) return } tree.setSelectedState([]) emitChange([]) } const Leaf = React.memo( ({ node, expanded, hasChildren, level, selected, tree, elementProps, }: RenderTreeNodePayload) => { const checked = mode === "multiple" ? tree.isNodeChecked(node.value) : false const indeterminate = mode === "multiple" ? tree.isNodeIndeterminate(node.value) : false const active = mode === "multiple" ? checked || indeterminate : selected const selectable = mode === "multiple" || !hasChildren return ( {}} gap={12} justify="space-between" flex={1} align="stretch" style={{ ...elementProps.style, overflowX: "hidden", cursor: "default", height: 36, paddingInline: 0, backgroundColor: undefined, }}> handleNodeSelect(node.value, hasChildren) // : undefined // } style={{ cursor: selectable ? "pointer" : "default", minWidth: 0, }}> tree.toggleExpanded(node.value)} /> ) } ) return (
{ if (nextOpened) { open() } else { close() } }} width="target" position="bottom-start" shadow="md" keepMounted={false}> {hasValue ? ( { event.preventDefault() event.stopPropagation() }} onClick={(event) => { event.preventDefault() event.stopPropagation() handleClear() }}> ) : null}
} rightSectionPointerEvents="all" rightSectionWidth={hasValue ? 54 : 30} title={triggerTitle} {...inputProps}> {shouldShowCountTag ? ( 已选 {currentValues.length} 项 ) : ( {displayValue || placeholder} )}
{ setSearchName(event.currentTarget.value) }} rightSection={ searchName ? ( { setSearchName("") }} style={{ cursor: "pointer", }} /> ) : ( ) } />
{filteredTreeData.length === 0 ? ( 暂无数据 ) : (
} />
)}
) } function UnstyledChevronButton({ expanded, hasChildren, onClick, }: { expanded: boolean hasChildren: boolean onClick: () => void }) { return ( ) }