From a1565dc18e18dacc7aa875574f44d8964cfb91ec Mon Sep 17 00:00:00 2001 From: zhangheng Date: Mon, 11 May 2026 18:28:26 +0800 Subject: [PATCH] fix(daily): fix multiselect --- .../report/components/DailyReportModal.tsx | 52 +- app/person/report/page.tsx | 51 +- .../tree-select/VirtualTreeSelect.module.css | 157 ++++++ components/tree-select/VirtualizedTree.tsx | 142 +++++ components/tree-select/index.tsx | 1 + components/tree-select/libs/util.ts | 225 ++++++++ components/tree-select/mantine-scrollbar.css | 60 +++ components/tree-select/tree.tsx | 488 ++++++++++++++++++ 8 files changed, 1090 insertions(+), 86 deletions(-) create mode 100644 components/tree-select/VirtualTreeSelect.module.css create mode 100644 components/tree-select/VirtualizedTree.tsx create mode 100644 components/tree-select/index.tsx create mode 100644 components/tree-select/libs/util.ts create mode 100644 components/tree-select/mantine-scrollbar.css create mode 100644 components/tree-select/tree.tsx diff --git a/app/person/report/components/DailyReportModal.tsx b/app/person/report/components/DailyReportModal.tsx index 7435666..ed52c21 100644 --- a/app/person/report/components/DailyReportModal.tsx +++ b/app/person/report/components/DailyReportModal.tsx @@ -9,6 +9,8 @@ import { useAllUserStore, usePermissionStore, } from "@/components/label/store/auth" +import { formatUserTreeOptions } from "@/components/tree-select/libs/util" +import { VirtualTreeSelect } from "@/components/tree-select/tree" import { Button, Group, @@ -46,42 +48,6 @@ function toTimeWithSeconds(value?: string) { return value.length === 5 ? `${value}:00` : value } -type LeaderTreeNode = { - title?: string - value?: string | number - children?: LeaderTreeNode[] -} - -function flattenLeaderTree( - nodes: LeaderTreeNode[], - prefixTitle: string[] = [] -): Array<{ label: string; value: string }> { - const result: Array<{ label: string; value: string }> = [] - - nodes.forEach((node) => { - const title = node.title ?? "" - const nextPrefix = title ? [...prefixTitle, title] : prefixTitle - const children = node.children ?? [] - const hasChildren = children.length > 0 - const valueRaw = node.value - - if (hasChildren) { - result.push(...flattenLeaderTree(children, nextPrefix)) - return - } - - if (valueRaw === undefined || valueRaw === null) return - if (typeof valueRaw === "string" && valueRaw.includes("-")) return - - result.push({ - label: nextPrefix.join(" / "), - value: String(valueRaw), - }) - }) - - return result -} - function HourRightSection() { return ( @@ -181,9 +147,10 @@ export default function DailyReportModal(props: { const user_name = usePermissionStore((s) => s.user_name) const treeData = useAllUserStore((s) => s.treeData) - const leaderOptions = useMemo(() => { - return flattenLeaderTree(treeData ?? []) - }, [treeData]) + const leaderOptions = useMemo( + () => formatUserTreeOptions(treeData ?? []), + [treeData] + ) const form = useForm({ initialValues: getEmptyFormValues(user_name), @@ -419,16 +386,15 @@ export default function DailyReportModal(props: { form.setFieldValue("project_id", value ?? "") } /> - { - const result: Array<{ label: string; value: string }> = [] - - nodes.forEach((node) => { - const title = node.title ?? "" - const nextPrefix = title ? [...prefixTitle, title] : prefixTitle - const children = node.children ?? [] - const hasChildren = children.length > 0 - const valueRaw = node.value - - if (hasChildren) { - result.push(...flattenLeaderTree(children, nextPrefix)) - return - } - - if (valueRaw === undefined || valueRaw === null) return - if (typeof valueRaw === "string" && valueRaw.includes("-")) return - - result.push({ - label: nextPrefix.join(" / "), - value: String(valueRaw), - }) - }) - - return result -} - function weekDayText(day: number) { const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"] return list[day] ?? "-" @@ -129,9 +95,10 @@ function InlineFilterField({ export default function PersonReportPage() { const treeData = useAllUserStore((s) => s.treeData) - const leaderOptions = useMemo(() => { - return flattenLeaderTree(treeData ?? []) - }, [treeData]) + const leaderOptions = useMemo( + () => formatUserTreeOptions(treeData ?? []), + [treeData] + ) const defaultFilters = useMemo(() => { return { @@ -618,11 +585,9 @@ export default function PersonReportPage() {
- setFilters((s) => ({ ...s, leader_uid: v }))} /> diff --git a/components/tree-select/VirtualTreeSelect.module.css b/components/tree-select/VirtualTreeSelect.module.css new file mode 100644 index 0000000..a32b6e5 --- /dev/null +++ b/components/tree-select/VirtualTreeSelect.module.css @@ -0,0 +1,157 @@ +.root { + width: 100%; +} + +.root :global(.mantine-Input-root), +.root :global(.mantine-Input-wrapper) { + width: 100%; +} + +.root :global(button.mantine-Input-input), +.dropdown :global(.mantine-Input-input) { + min-height: 32px; + border: 1px solid #e5e6eb; + border-radius: 4px; + background: #f7f8fa; + color: #1d2129; + font-size: 14px; + box-shadow: none; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.root :global(button.mantine-Input-input) { + display: flex; + width: 100%; + align-items: center; + padding-inline: 12px; + text-align: left; +} + +.root[data-opened="true"] :global(button.mantine-Input-input), +.dropdown :global(.mantine-Input-input:focus), +.dropdown :global(.mantine-Input-input:focus-visible) { + border-color: #1874ff; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(24, 116, 255, 0.12); +} + +.root :global(.mantine-Input-section), +.dropdown :global(.mantine-Input-section) { + color: #86909c; +} + +.root :global(button.mantine-Input-input:disabled) { + border-color: #e5e6eb; + background: #f2f3f5; + color: #c9cdd4; +} + +.dropdown { + border: 1px solid #e5e6eb; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.dropdown :global(.mantine-Input-input::placeholder) { + color: #86909c; +} + +.triggerText { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.triggerActions { + display: inline-flex; + align-items: center; + gap: 6px; + color: #86909c; +} + +.triggerValue { + color: #1d2129; +} + +.triggerPlaceholder { + color: #86909c; +} + +.triggerCountTag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 22px; + padding-inline: 10px; + border-radius: 999px; + background: #e7efff; + color: #1874ff; + font-size: 12px; + font-weight: 500; + line-height: 20px; +} + +.clearButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + color: #86909c; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.clearButton:hover { + background: #f2f3f5; + color: #4e5969; +} + +.searchArea { + padding: 8px 8px 10px; +} + +.searchField { + width: 100%; +} + +.treePanel { + overflow: hidden; + border-top: 1px solid #f2f3f5; + padding: 4px 8px 8px 0; +} + +.emptyState { + border-top: 1px solid #f2f3f5; + padding: 8px; +} + +.treeRow { + border-radius: 4px; + transition: background-color 0.2s ease; +} + +/* .treeRow[data-active="true"] { + background: #f2f3f5; +} */ + +.treeContent { + min-width: 0; +} + +.levelSpacer { + display: inline-block; + flex: 0 0 auto; + min-width: 0; +} diff --git a/components/tree-select/VirtualizedTree.tsx b/components/tree-select/VirtualizedTree.tsx new file mode 100644 index 0000000..898a982 --- /dev/null +++ b/components/tree-select/VirtualizedTree.tsx @@ -0,0 +1,142 @@ +// VirtualizedTree.tsx +import { + RenderTreeNodePayload, + TreeNodeData, + UseTreeReturnType, +} from "@mantine/core" +import React, { CSSProperties, useMemo } from "react" +import { List } from "react-window" +import "./mantine-scrollbar.css" + +export interface VirtualizedTreeProps { + /** Mantine tree controller (from useTree()) — optional, if not provided this component won't control state */ + tree: UseTreeReturnType + + /** Mantine tree data */ + data: TreeNodeData[] + + /** renderNode keeps same signature as Mantine: receives RenderTreeNodePayload */ + renderNode: (payload: RenderTreeNodePayload) => React.ReactNode + + /** pixels to indent per level (matches Mantine's levelOffset) */ + levelOffset?: number + + /** react-window container height */ + height?: string | number + + /** react-window item (row) height */ + itemSize?: number + + /** overscan rows */ + overscanCount?: number +} + +/** 小工具:把 TreeNodeData 扁平化为 [node, level] 列表,依据 tree.expandedState */ +function flattenByExpanded( + nodes: TreeNodeData[], + expandedState: Record, + level = 0 +): { node: TreeNodeData; level: number }[] { + const res: { node: TreeNodeData; level: number }[] = [] + + for (const node of nodes) { + res.push({ node, level }) + const value = node.value + const isExpanded = !!expandedState?.[String(value)] + if (isExpanded && node.children && node.children.length > 0) { + res.push(...flattenByExpanded(node.children, expandedState, level + 1)) + } + } + + return res +} + +/** + * VirtualizedTree - Mantine v8 compatible virtualized Tree + * - Accepts tree (useTree() result) and data: TreeNodeData[] + * - Passes RenderTreeNodePayload to renderNode exactly like Mantine + * - Uses react-window FixedSizeList for virtualization + */ +export function VirtualizedTree({ + tree, + data, + renderNode, + // levelOffset = 24, + height = 400, + itemSize = 36, + overscanCount = 5, +}: VirtualizedTreeProps) { + // 扁平化数据:根据 tree.expandedState 决定哪些子节点可见 + const flattened = useMemo( + () => + flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0), + [data, tree] + ) + // react-window 的行渲染 + /** 单行渲染组件 */ + const Row = ({ index, style }: { index: number; style: CSSProperties }) => { + const { node, level } = flattened[index] + + // 构造 Mantine 的 RenderTreeNodePayload + const nodeValue = String(node.value) + const hasChildren = Array.isArray(node.children) && node.children.length > 0 + const expanded = !!(tree && (tree as any).expandedState?.[nodeValue]) + const selected = !!( + tree && (tree as any).selectedState?.includes?.(nodeValue) + ) + const hovered = tree ? (tree as any).hoveredNode === nodeValue : false + + // elementProps: 与 Mantine Tree 内部的 props shape 对齐,外部的 renderNode 会 spread 这个对象 + const elementProps = { + className: "", + style: { + display: "flex", + alignItems: "center", + width: "100%", + } as React.CSSProperties, + onClick: (e: React.MouseEvent) => { + // 保持与 Mantine Tree 一致的基础行为:选中、展开切换由 tree 控制 + // 点击行为在业务端通常由 renderNode 决定(例如中间区域用于 toggleExpanded) + // 这里我们调用 tree.toggleExpanded 以保证基础交互一致(不会阻止外部覆盖) + if (tree && typeof tree.toggleExpanded === "function") { + tree.toggleExpanded(nodeValue) + } + false && console.log(e) + }, + "data-selected": selected as boolean | undefined, + "data-value": nodeValue, + "data-hovered": hovered as boolean | undefined, + } + + const payload: RenderTreeNodePayload = { + level, + expanded, + hasChildren, + selected, + node, + tree, + elementProps, + } + + // paddingLeft 模拟 Mantine 的 levelOffset 行为(renderNode 可使用 elementProps.style 覆盖) + const paddedStyle: React.CSSProperties = { + ...style, + boxSizing: "border-box", + overflow: "hidden", + } + + // 将 style 作为容器传入,Mantine 的 renderNode 也会接受 elementProps.style + return
{renderNode(payload)}
+ } + + return ( + } + rowProps={{}} + overscanCount={overscanCount}> + ) +} diff --git a/components/tree-select/index.tsx b/components/tree-select/index.tsx new file mode 100644 index 0000000..517ad6a --- /dev/null +++ b/components/tree-select/index.tsx @@ -0,0 +1 @@ +export { VirtualTreeSelect as default, VirtualTreeSelect } from "./tree" diff --git a/components/tree-select/libs/util.ts b/components/tree-select/libs/util.ts new file mode 100644 index 0000000..7fd4661 --- /dev/null +++ b/components/tree-select/libs/util.ts @@ -0,0 +1,225 @@ +import { TreeNodeData } from "@mantine/core" + +const TREE_PATH_SEPARATOR = " / " + +/** + * 判断一个节点本身是否命中 + * 父节点看 label(group_name),叶子看 label(member_name) + */ +const nodeMatch = (node: TreeNodeData, search: string): boolean => { + if (!search) return true // 空搜索默认全过 + const text = String(node.label).toLowerCase() + return text.includes(search.toLowerCase()) +} + +/* ------------------------------------------------- + * 1. DFS 自上而下,匹配即保留,否则剪掉整枝 + * ------------------------------------------------- */ +export const filterTreeData_dfs = ( + treeData: TreeNodeData[], + searchName: string +): TreeNodeData[] => { + if (!searchName.trim()) return treeData // 空搜索直接返回原树 + + const loop = (list: TreeNodeData[]): TreeNodeData[] => { + const result: TreeNodeData[] = [] + + for (const n of list) { + // 1. 只要节点本身命中,就整枝保留(children 不用再判断) + if (nodeMatch(n, searchName)) { + result.push({ ...n }) // 浅拷贝一份,避免污染原树 + continue + } + // 2. 本身没命中,再看 children 有没有命中 + if (n.children) { + const newChildren = loop(n.children) + if (newChildren.length) { + result.push({ ...n, children: newChildren }) + } + } + } + return result + } + + return loop(treeData) +} + +// 辅助:根据 value 查找 label +export interface FindResult { + path: string // 完整 label 路径,用 / 分隔 + node: TreeNodeData | null +} + +export interface TreeValueMeta { + path: string + node: TreeNodeData +} + +export const buildTreeValueMap = ( + nodes: TreeNodeData[], + pathArr: string[] = [], + acc: Record = {} +): Record => { + for (const node of nodes) { + const currentPath = [...pathArr, String(node.label)] + acc[node.value] = { + path: currentPath.join(TREE_PATH_SEPARATOR), + node, + } + + if (node.children?.length) { + buildTreeValueMap(node.children, currentPath, acc) + } + } + + return acc +} + +export const findLabel = ( + nodes: TreeNodeData[], + targetValue: string, + pathArr: string[] = [] // 内部用,调用端无需传入 +): FindResult => { + for (const node of nodes) { + const curPath = [...pathArr, node.label as string] // 复制一份,避免污染上层 + if (node.value === targetValue) { + return { path: curPath.join(TREE_PATH_SEPARATOR), node } + } + if (node.children) { + const found = findLabel(node.children, targetValue, curPath) + if (found.node) return found + } + } + return { path: "", node: null } +} + +export const findNodesByValues = ( + valueMap: Record, + targetValues: string[] +): TreeNodeData[] => + targetValues.reduce((acc, value) => { + const targetNode = valueMap[value]?.node + + if (targetNode) { + acc.push(targetNode) + } + + return acc + }, []) + +export const getLabelsByValues = ( + valueMap: Record, + targetValues: string[] +): string[] => + targetValues.reduce((acc, value) => { + const label = valueMap[value]?.path + + if (label) { + acc.push(label) + } + + return acc + }, []) + +const findNodeByValue = ( + nodes: TreeNodeData[], + targetValue: string +): TreeNodeData | null => { + for (const node of nodes) { + if (node.value === targetValue) { + return node + } + + if (node.children?.length) { + const found = findNodeByValue(node.children, targetValue) + + if (found) { + return found + } + } + } + + return null +} + +const collectLeafValues = ( + nodes: TreeNodeData[], + acc: string[] = [] +): string[] => { + for (const node of nodes) { + if (node.children?.length) { + collectLeafValues(node.children, acc) + } else { + acc.push(node.value) + } + } + + return acc +} + +export const getDescendantLeafValues = ( + nodes: TreeNodeData[], + targetValue: string +): string[] => { + const targetNode = findNodeByValue(nodes, targetValue) + + if (!targetNode) { + return [] + } + + if (!targetNode.children?.length) { + return [targetNode.value] + } + + return collectLeafValues(targetNode.children) +} + +export const getStateFromDataByName = ( + data: TreeNodeData[], + record: Record = {} +): Record => { + let result = data?.reduce((acc, item) => { + let child_acc = {} + if (item.children && item.children?.length) { + acc[item.value] = true + child_acc = getStateFromDataByName(item.children, record) + } + return { ...acc, ...child_acc } + }, record) + return result +} + +export interface UserTreeData { + title?: string + value?: string | number + children?: UserTreeData[] + disabled?: boolean +} + +export function formatUserTreeOptions(nodes: UserTreeData[]): TreeNodeData[] { + return nodes.flatMap((node) => { + const children = formatUserTreeOptions(node.children ?? []) + const title = String(node.title ?? "").trim() + const rawValue = node.value + const shouldSkipCurrentNode = + node.disabled || + rawValue === undefined || + rawValue === null || + !title || + (typeof rawValue === "string" && + rawValue.includes("-") && + children.length === 0) + + if (shouldSkipCurrentNode) { + return children + } + + return [ + { + label: title, + value: String(rawValue), + children: children.length > 0 ? children : undefined, + }, + ] + }) +} diff --git a/components/tree-select/mantine-scrollbar.css b/components/tree-select/mantine-scrollbar.css new file mode 100644 index 0000000..52c8d6f --- /dev/null +++ b/components/tree-select/mantine-scrollbar.css @@ -0,0 +1,60 @@ +/* 根容器 */ +.custom-scrollbar { + scrollbar-gutter: stable both-edges; + border-radius: 4px; /* Mantine 默认圆角 */ +} + +/* Firefox */ +.custom-scrollbar { + scrollbar-width: thin; /* 细滚动条 */ + scrollbar-color: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.4)) + transparent; + scroll-behavior: smooth; + border-radius: 4px; /* Mantine 默认圆角 */ +} + +/* Webkit 滚动条(Chrome / Edge / Safari) */ +.custom-scrollbar::-webkit-scrollbar { + width: 8px; /* Mantine 默认宽度 */ + height: 8px; + border-radius: 4px; /* Mantine 默认圆角 */ +} + +.custom-scrollbar::-webkit-scrollbar-track { + background-color: transparent; /* Mantine 无轨道背景 */ + border-radius: 4px; /* Mantine 默认圆角 */ +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.25); /* Mantine 默认 thumb 颜色 */ + border-radius: 4px; /* Mantine 默认圆角 */ + transition: background-color 150ms ease; + border-radius: 4px; /* Mantine 默认圆角 */ +} + +.custom-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.35); /* hover 时更深 */ + border-radius: 4px; /* Mantine 默认圆角 */ +} + +/* Active(按住拖动) */ +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background-color: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.4)); + border-radius: 4px; /* Mantine 默认圆角 */ +} + +/* Hover 效果(Firefox) */ +.custom-scrollbar:hover { + scrollbar-color: light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.3)) + transparent; + scroll-behavior: smooth; + border-radius: 4px; /* Mantine 默认圆角 */ +} + +/** + * 支持垂直 + 水平滚动条交汇区域(ScrollArea 的 corner) + */ +.custom-scrollbar-corner { + background-color: transparent; + border-radius: 4px; /* Mantine 默认圆角 */ +} diff --git a/components/tree-select/tree.tsx b/components/tree-select/tree.tsx new file mode 100644 index 0000000..9288ec6 --- /dev/null +++ b/components/tree-select/tree.tsx @@ -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(() => { + 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 ( + {}} + gap={12} + justify="space-between" + flex={1} + align="stretch" + style={{ + ...elementProps.style, + overflowX: "hidden", + cursor: "default", + height: 36, + paddingInline: 0, + backgroundColor: undefined, + }}> + handleNodeSelect(node.value, hasChildren) + : undefined + } + style={{ + cursor: selectable ? "pointer" : "default", + minWidth: 0, + }}> + + + tree.toggleExpanded(node.value)} + /> + + + ) + } + ) + + return ( + +
+ { + if (nextOpened) { + open() + } else { + close() + } + }} + width="target" + position="bottom-start" + shadow="md" + keepMounted={false}> + + + {hasValue ? ( + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + handleClear() + }}> + + + ) : null} + +
+ } + rightSectionPointerEvents="all" + rightSectionWidth={hasValue ? 54 : 30} + title={triggerTitle} + {...inputProps}> + {shouldShowCountTag ? ( + + 已选 {currentValues.length} 项 + + ) : ( + + {displayValue || placeholder} + + )} + + + +
+ { + setSearchName(event.currentTarget.value) + }} + rightSection={ + searchName ? ( + { + setSearchName("") + }} + style={{ + cursor: "pointer", + }} + /> + ) : ( + + ) + } + /> +
+ {filteredTreeData.length === 0 ? ( + + 暂无数据 + + ) : ( +
+ } + /> +
+ )} +
+ +
+ + ) +} + +function UnstyledChevronButton({ + expanded, + hasChildren, + onClick, +}: { + expanded: boolean + hasChildren: boolean + onClick: () => void +}) { + return ( + + ) +}