fix(daily): fix multiselect
This commit is contained in:
488
components/tree-select/tree.tsx
Normal file
488
components/tree-select/tree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user