225 lines
5.5 KiB
TypeScript
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>
|
|
)
|
|
}
|