489 lines
13 KiB
TypeScript
489 lines
13 KiB
TypeScript
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<string[]>(() => {
|
|
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 (
|
|
<Flex
|
|
{...elementProps}
|
|
className={classes.treeRow}
|
|
data-active={active ? "true" : "false"}
|
|
onClick={() => {}}
|
|
gap={12}
|
|
justify="space-between"
|
|
flex={1}
|
|
align="stretch"
|
|
style={{
|
|
...elementProps.style,
|
|
overflowX: "hidden",
|
|
cursor: "default",
|
|
height: 36,
|
|
paddingInline: 0,
|
|
backgroundColor: undefined,
|
|
}}>
|
|
<Flex
|
|
className={classes.treeContent}
|
|
align="center"
|
|
gap={12}
|
|
flex={1}
|
|
onClick={
|
|
selectable
|
|
? () => handleNodeSelect(node.value, hasChildren)
|
|
: undefined
|
|
}
|
|
style={{
|
|
cursor: selectable ? "pointer" : "default",
|
|
minWidth: 0,
|
|
}}>
|
|
<span
|
|
aria-hidden="true"
|
|
className={classes.levelSpacer}
|
|
style={{ width: `${level * 20}px` }}
|
|
/>
|
|
{mode === "multiple" ? (
|
|
<Checkbox.Indicator
|
|
checked={checked}
|
|
indeterminate={indeterminate}
|
|
size="xs"
|
|
radius="sm"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
handleNodeSelect(node.value, hasChildren)
|
|
}}
|
|
/>
|
|
) : (
|
|
<Radio.Indicator
|
|
checked={selected}
|
|
size="xs"
|
|
onClick={
|
|
selectable
|
|
? (event) => {
|
|
event.stopPropagation()
|
|
handleNodeSelect(node.value, hasChildren)
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
<Text
|
|
lh="22px"
|
|
size="15px"
|
|
title={String(node.label)}
|
|
style={{
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}>
|
|
{node.label}
|
|
</Text>
|
|
</Flex>
|
|
<Flex gap={24} align="center">
|
|
<UnstyledChevronButton
|
|
expanded={expanded}
|
|
hasChildren={hasChildren}
|
|
onClick={() => tree.toggleExpanded(node.value)}
|
|
/>
|
|
</Flex>
|
|
</Flex>
|
|
)
|
|
}
|
|
)
|
|
|
|
return (
|
|
<Input.Wrapper label={label}>
|
|
<div className={classes.root} data-opened={opened ? "true" : "false"}>
|
|
<Popover
|
|
opened={opened}
|
|
onChange={(nextOpened) => {
|
|
if (nextOpened) {
|
|
open()
|
|
} else {
|
|
close()
|
|
}
|
|
}}
|
|
width="target"
|
|
position="bottom-start"
|
|
shadow="md"
|
|
keepMounted={false}>
|
|
<Popover.Target>
|
|
<Input
|
|
component="button"
|
|
type="button"
|
|
pointer
|
|
onClick={toggle}
|
|
rightSection={
|
|
<div className={classes.triggerActions}>
|
|
{hasValue ? (
|
|
<span
|
|
role="button"
|
|
aria-label="清空已选项"
|
|
className={classes.clearButton}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
handleClear()
|
|
}}>
|
|
<IconX size={14} />
|
|
</span>
|
|
) : null}
|
|
<Combobox.Chevron />
|
|
</div>
|
|
}
|
|
rightSectionPointerEvents="all"
|
|
rightSectionWidth={hasValue ? 54 : 30}
|
|
title={triggerTitle}
|
|
{...inputProps}>
|
|
{shouldShowCountTag ? (
|
|
<span className={classes.triggerCountTag}>
|
|
已选 {currentValues.length} 项
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={`${classes.triggerText} ${
|
|
displayValue
|
|
? classes.triggerValue
|
|
: classes.triggerPlaceholder
|
|
}`}>
|
|
{displayValue || placeholder}
|
|
</span>
|
|
)}
|
|
</Input>
|
|
</Popover.Target>
|
|
<Popover.Dropdown className={classes.dropdown} p={0}>
|
|
<div className={classes.searchArea}>
|
|
<TextInput
|
|
className={classes.searchField}
|
|
radius="md"
|
|
placeholder="搜索名称"
|
|
value={searchName}
|
|
onChange={(event) => {
|
|
setSearchName(event.currentTarget.value)
|
|
}}
|
|
rightSection={
|
|
searchName ? (
|
|
<CloseButton
|
|
size="1rem"
|
|
aria-label="Clear input"
|
|
onClick={() => {
|
|
setSearchName("")
|
|
}}
|
|
style={{
|
|
cursor: "pointer",
|
|
}}
|
|
/>
|
|
) : (
|
|
<IconSearch size="1rem" />
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
{filteredTreeData.length === 0 ? (
|
|
<Stack
|
|
className={classes.emptyState}
|
|
flex={1}
|
|
h="250px"
|
|
justify="center"
|
|
align="center">
|
|
<Text c="dimmed">暂无数据</Text>
|
|
</Stack>
|
|
) : (
|
|
<div className={classes.treePanel}>
|
|
<VirtualizedTree
|
|
height="450px"
|
|
tree={tree}
|
|
data={filteredTreeData}
|
|
levelOffset={30}
|
|
renderNode={(payload) => <Leaf {...payload} />}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
</div>
|
|
</Input.Wrapper>
|
|
)
|
|
}
|
|
|
|
function UnstyledChevronButton({
|
|
expanded,
|
|
hasChildren,
|
|
onClick,
|
|
}: {
|
|
expanded: boolean
|
|
hasChildren: boolean
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onClick()
|
|
}}
|
|
aria-label={expanded ? "收起节点" : "展开节点"}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 16,
|
|
height: 16,
|
|
border: 0,
|
|
padding: 0,
|
|
background: "transparent",
|
|
cursor: hasChildren ? "pointer" : "default",
|
|
visibility: hasChildren ? "visible" : "hidden",
|
|
}}>
|
|
<IconChevronUp
|
|
size={16}
|
|
stroke={2.5}
|
|
style={{
|
|
transform: expanded ? "rotate(180deg)" : "rotate(90deg)",
|
|
}}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|