Files
labelmain-demo/components/tree-select/tree.tsx
2026-02-03 18:05:47 +08:00

225 lines
5.5 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react"
import {
Popover,
Text,
UnstyledButton,
Input,
Checkbox,
useTree,
getTreeExpandedState,
RenderTreeNodePayload,
Flex,
TreeNodeData,
Combobox,
CloseButton,
Stack,
TextInput,
InputProps,
} from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import { IconChevronUp, IconSearch } from "@tabler/icons-react"
import { VirtualizedTree } from "./VirtualizedTree"
import {
filterTreeData_dfs,
findLabel,
getStateFromDataByName,
} from "./libs/util"
interface VirtualTreeSelectProps extends InputProps {
data: TreeNodeData[]
value: string
onChange: (value: string) => void
placeholder?: string
setCurrCityCode: (code: string) => void
}
export function VirtualTreeSelect({
data,
value,
onChange,
setCurrCityCode,
...props
}: VirtualTreeSelectProps) {
const [opened, { toggle }] = useDisclosure(false)
const [searchName, setSearchName] = useState<string>("")
const filteredTreeData = useMemo(() => {
return filterTreeData_dfs(data, searchName)
}, [searchName, data])
const tree = useTree({
initialExpandedState: getTreeExpandedState(filteredTreeData, []),
initialCheckedState: [],
})
useEffect(() => {
if (filteredTreeData?.length && searchName) {
let state = getStateFromDataByName(filteredTreeData)
tree.setExpandedState(state)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredTreeData, searchName])
useEffect(() => {
setCurrCityCode(tree.selectedState[0])
let label = findLabel(data, tree.selectedState[0])
onChange(label.path || "")
}, [data, onChange, setCurrCityCode, tree.selectedState])
const ChevronIcon = ({
hasChildren,
expanded,
}: {
hasChildren: boolean
expanded: boolean
}) => {
if (hasChildren) {
return (
<IconChevronUp
size={16}
stroke={2.5}
style={{
transform: expanded ? "rotate(180deg)" : "rotate(90deg)",
}}
/>
)
}
return null
}
const Leaf = React.memo(
({
node,
expanded,
elementProps,
selected,
tree,
}: RenderTreeNodePayload) => {
return (
<Flex
{...elementProps}
onClick={() => {}}
gap={12}
px={16}
justify={"space-between"}
flex={1}
align="stretch"
bdrs={4}
pl={(node.nodeProps?.level || 1) * 16}
style={{
overflowX: "hidden",
cursor: "auto",
height: 36,
}}>
<Flex align={"center"} gap={12} flex={1} onClick={() => {}}>
<Checkbox.Indicator
checked={selected}
size="xs"
radius="sm"
onClick={() => {
if (!selected) {
tree.select(node.value)
} else {
tree.select("")
}
}}
/>
<Text
lh={"22px"}
size="15px"
title={node.label as string}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{node.label}
</Text>
</Flex>
<Flex gap={24} align={"center"}>
<UnstyledButton
h={16}
style={{
display: "flex",
}}
onClick={() => tree.toggleExpanded(node.value)}>
<ChevronIcon
hasChildren={!!(node.children && node.children.length)}
expanded={expanded}
/>
</UnstyledButton>
</Flex>
</Flex>
)
}
)
return (
<Popover
opened={opened}
onChange={toggle}
width="target"
position="bottom-start"
shadow="md"
keepMounted={false} // 性能优化:关闭时销毁 DOM
>
<Popover.Target>
<Input
component="button"
pointer
onClick={toggle}
rightSection={<Combobox.Chevron />}
// style={{ width: 250 }}
{...props}>
{value || "请选择..."}
</Input>
</Popover.Target>
<Popover.Dropdown p={0}>
<TextInput
m={"xs"}
radius={"md"}
placeholder="搜索省、市、区名称"
value={searchName}
onChange={(e) => {
setSearchName(e.currentTarget.value)
}}
rightSection={
searchName ? (
<CloseButton
size="1rem"
aria-label="Clear input"
onClick={() => {
setSearchName("")
}}
style={{
display: searchName ? undefined : "none",
cursor: "pointer",
}}
/>
) : (
<IconSearch size="1rem" />
)
}
/>
{filteredTreeData.length === 0 ? (
<Stack flex={1} h={"250px"} justify="center" align="center">
<Text c="dimmed"></Text>
</Stack>
) : (
<>
{/* 虚拟列表容器:必须有固定高度 */}
<VirtualizedTree
height={"450px"}
tree={tree}
data={filteredTreeData}
levelOffset={30}
renderNode={(payload) => <Leaf {...payload} />}
/>
</>
)}
</Popover.Dropdown>
</Popover>
)
}