完善选择地区的树组件
This commit is contained in:
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user