完善选择地区的树组件

This commit is contained in:
Eamon-meng
2026-06-29 14:10:33 +08:00
parent 4f165c6a97
commit c74e19a062

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import {CheckIcon, ChevronsUpDown} from 'lucide-react' import {CheckIcon, ChevronDown, ChevronRight, ChevronsUpDown} from 'lucide-react'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
@@ -10,7 +10,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import {ReactNode, useRef, useState} from 'react' import {ReactNode, useState} from 'react'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
type ComboboxItem = { type ComboboxItem = {
@@ -28,6 +28,7 @@ type ComboboxProps = {
value?: string[] value?: string[]
onChange?: (value: string[]) => void onChange?: (value: string[]) => void
children?: React.ReactNode children?: React.ReactNode
searchPlaceholder?: string
} }
export function Combobox(props: ComboboxProps) { export function Combobox(props: ComboboxProps) {
@@ -56,22 +57,51 @@ export function Combobox(props: ComboboxProps) {
const [wait, setWait] = useState(false) const [wait, setWait] = useState(false)
const [filter, setFilter] = useState<string>('') const [filter, setFilter] = useState<string>('')
const [filtered, setFiltered] = useState<ComboboxItem[]>([]) const [filtered, setFiltered] = useState<ComboboxItem[]>([])
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())
const onFilter = async () => { const toggleExpand = (key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
}
else {
next.add(key)
}
return next
})
}
const onSearch = async () => {
if (wait) return if (wait) return
const cond = filter.trim() const cond = filter.trim()
console.log('onFilter', cond)
setWait(true) setWait(true)
if (cond.length > 0) { if (cond.length > 0) {
setFiltered(mapFilter(JSON.parse(JSON.stringify(props.options)), cond)) const result = mapFilter(JSON.parse(JSON.stringify(props.options)), cond)
setFiltered(result)
setExpandedKeys(collectAllKeys(result))
} }
else { else {
setFiltered(props.options) setFiltered(props.options)
setExpandedKeys(new Set())
} }
console.log('onFilter end')
setWait(false) setWait(false)
} }
const collectAllKeys = (items: ComboboxItem[]): Set<string> => {
const keys = new Set<string>()
const walk = (list: ComboboxItem[]) => {
list.forEach((item) => {
if (item.children?.length) {
keys.add(item.value)
walk(item.children)
}
})
}
walk(items)
return keys
}
const mapFilter = (items: ComboboxItem[], cond: string): ComboboxItem[] => { const mapFilter = (items: ComboboxItem[], cond: string): ComboboxItem[] => {
const nItems: ComboboxItem[] = [] const nItems: ComboboxItem[] = []
items.forEach((item) => { items.forEach((item) => {
@@ -97,6 +127,7 @@ export function Combobox(props: ComboboxProps) {
if (status) { if (status) {
setFiltered(props.options) setFiltered(props.options)
setFilter('') setFilter('')
setExpandedKeys(new Set())
} }
}}> }}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -117,18 +148,18 @@ export function Combobox(props: ComboboxProps) {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="p-0 rounded-lg w-[var(--radix-popover-trigger-width)] h-[var(--radix-popover-content-available-height)] flex flex-col overflow-hidden" className="p-0 rounded-lg w-(--radix-popover-trigger-width) h-(--radix-popover-content-available-height) flex flex-col overflow-hidden"
align="start" align="start"
collisionPadding={6} collisionPadding={6}
> >
<div className="p-2 flex gap-2 flex-none"> <div className="p-2 flex gap-2 flex-none">
<Input <Input
className="h-9 placeholder:text-weak placeholder:text-sm" className="h-9 placeholder:text-weak placeholder:text-sm"
placeholder="搜索地区" placeholder={props.searchPlaceholder || '搜索地区'}
value={filter} value={filter}
onChange={event => setFilter(event.target.value)} onChange={event => setFilter(event.target.value)}
/> />
<Button className="h-9" onClick={onFilter} disabled={wait}> <Button className="h-9" onClick={onSearch} disabled={wait}>
</Button> </Button>
</div> </div>
@@ -136,11 +167,13 @@ export function Combobox(props: ComboboxProps) {
<OptionList <OptionList
options={filtered} options={filtered}
value={values} value={values}
onChange={(value) => { expandedKeys={expandedKeys}
console.log(value.map(item => item.value)) onToggleExpand={toggleExpand}
props.onChange?.(value.map(item => item.value)) onChange={(pathValue) => {
props.onChange?.(pathValue)
setOpen(false) setOpen(false)
}}/> }}
/>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -150,51 +183,76 @@ export function Combobox(props: ComboboxProps) {
function OptionList(props: { function OptionList(props: {
options: ComboboxItem[] options: ComboboxItem[]
value: string[] value: string[]
onChange: (value: ComboboxItem[]) => void expandedKeys: Set<string>
path?: ComboboxItem[] onToggleExpand: (key: string) => void
onChange: (value: string[]) => void
path?: string[]
depth?: number depth?: number
}) { }) {
const depth = props.depth || 0 const depth = props.depth || 0
const parent = props.path || [] const parentPath = props.path || []
const indent = depth * 16
return ( return (
<ul style={{ <ul>
marginLeft: `${indent}px`,
}}>
{props.options.map((item, i) => { {props.options.map((item, i) => {
const path = [...parent, item] const path = [...parentPath, item.value]
const pathValue = path.map(item => item.value) const hasChildren = !!item.children?.length
const equal = pathValue.join(`.`) === props.value?.join('.') const expanded = props.expandedKeys.has(item.value)
const isCurrentLevel = path.length === props.value.filter(Boolean).length
const isActivePath = path.every((v, idx) => v === props.value[idx])
return ( return (
<li key={i}> <li key={i}>
<OptionItem key={`${i}`} item={item} active={equal} onChange={() => props.onChange?.(path)}/> <div
{item.children?.length className={merge(
&& <OptionList depth={depth + 1} options={item.children} value={props.value} path={path} onChange={props.onChange}/> `transition-colors duration-100 ease-in-out`,
} `pr-4 py-2 rounded-md`,
`flex items-center gap-1`,
`hover:bg-muted hover:text-foreground cursor-pointer`,
isActivePath ? 'text-foreground' : 'text-muted-foreground',
)}
style={{paddingLeft: `${depth * 20 + 16}px`}}
>
{hasChildren ? (
<span
className="flex-none cursor-pointer p-0.5 hover:bg-muted rounded shrink-0"
onClick={(e) => {
e.stopPropagation()
props.onToggleExpand(item.value)
}}
>
{expanded
? <ChevronDown className="h-4 w-4"/>
: <ChevronRight className="h-4 w-4"/>
}
</span>
) : (
<span className="w-5 shrink-0"/>
)}
<span
className="flex-1 min-w-0 truncate"
onClick={() => props.onChange(path)}
>
{item.label}
</span>
{isCurrentLevel && isActivePath && (
<CheckIcon className="h-4 w-4 shrink-0"/>
)}
</div>
{hasChildren && expanded && (
<OptionList
depth={depth + 1}
options={item.children!}
value={props.value}
expandedKeys={props.expandedKeys}
onToggleExpand={props.onToggleExpand}
onChange={props.onChange}
path={path}
/>
)}
</li> </li>
) )
})} })}
</ul> </ul>
) )
} }
function OptionItem(props: {
key: string
item: ComboboxItem
active: boolean
onChange?: () => void
}) {
return (
<div
className={merge(
`transition-colors, duration-100 ease-in-out`,
`px-4 py-2 text-muted-foreground rounded-md`,
`flex justify-between items-center`,
`hover:bg-muted hover:text-foreground`,
)}
onClick={props.onChange}>
{props.item.label}
</div>
)
}