fix(daily): fix multiselect

This commit is contained in:
zhangheng
2026-05-11 18:28:26 +08:00
parent 332d6ab5f1
commit a1565dc18e
8 changed files with 1090 additions and 86 deletions

View File

@@ -0,0 +1,488 @@
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>
)
}