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,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;
}

View File

@@ -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<string, boolean>,
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 <div style={paddedStyle}>{renderNode(payload)}</div>
}
return (
<List
rowCount={flattened.length}
style={{ height, width: "100%" }}
className="custom-scrollbar"
rowHeight={itemSize}
rowComponent={({ index, style }) => <Row index={index} style={style} />}
rowProps={{}}
overscanCount={overscanCount}></List>
)
}

View File

@@ -0,0 +1 @@
export { VirtualTreeSelect as default, VirtualTreeSelect } from "./tree"

View File

@@ -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<string, TreeValueMeta> = {}
): Record<string, TreeValueMeta> => {
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<string, TreeValueMeta>,
targetValues: string[]
): TreeNodeData[] =>
targetValues.reduce<TreeNodeData[]>((acc, value) => {
const targetNode = valueMap[value]?.node
if (targetNode) {
acc.push(targetNode)
}
return acc
}, [])
export const getLabelsByValues = (
valueMap: Record<string, TreeValueMeta>,
targetValues: string[]
): string[] =>
targetValues.reduce<string[]>((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<string, any> = {}
): Record<string, any> => {
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,
},
]
})
}

View File

@@ -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 默认圆角 */
}

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>
)
}