201 lines
5.3 KiB
TypeScript
201 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import {CheckIcon, ChevronsUpDown} from 'lucide-react'
|
|
|
|
import {merge} from '@/lib/utils'
|
|
import {Button} from '@/components/ui/button'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import {ReactNode, useRef, useState} from 'react'
|
|
import {Input} from '@/components/ui/input'
|
|
|
|
type ComboboxItem = {
|
|
value: string
|
|
label: ReactNode
|
|
alias?: string[]
|
|
children?: ComboboxItem[]
|
|
labelBuild?: (item: ComboboxItem) => string
|
|
}
|
|
|
|
type ComboboxProps = {
|
|
className?: string
|
|
placeholder?: string
|
|
options: ComboboxItem[]
|
|
value?: string[]
|
|
onChange?: (value: string[]) => void
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
export function Combobox(props: ComboboxProps) {
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const getLabel = (item: ComboboxItem) => {
|
|
return typeof item.label === 'object' || typeof item.label === 'function'
|
|
? item.labelBuild?.(item)
|
|
: `${item?.label}`
|
|
}
|
|
|
|
let items: ComboboxItem[] | undefined = props.options
|
|
const label: string[] = []
|
|
const values: string[] = []
|
|
props.value?.forEach((value) => {
|
|
if (items) {
|
|
const curr = items.find(item => item.value === value)
|
|
if (curr) {
|
|
items = curr.children
|
|
values.push(curr.value)
|
|
label.push(getLabel(curr) || '-')
|
|
}
|
|
}
|
|
})
|
|
|
|
const [wait, setWait] = useState(false)
|
|
const [filter, setFilter] = useState<string>('')
|
|
const [filtered, setFiltered] = useState<ComboboxItem[]>([])
|
|
|
|
const onFilter = async () => {
|
|
if (wait) return
|
|
const cond = filter.trim()
|
|
console.log('onFilter', cond)
|
|
setWait(true)
|
|
if (cond.length > 0) {
|
|
setFiltered(mapFilter(JSON.parse(JSON.stringify(props.options)), cond))
|
|
}
|
|
else {
|
|
setFiltered(props.options)
|
|
}
|
|
console.log('onFilter end')
|
|
setWait(false)
|
|
}
|
|
|
|
const mapFilter = (items: ComboboxItem[], cond: string): ComboboxItem[] => {
|
|
const nItems: ComboboxItem[] = []
|
|
items.forEach((item) => {
|
|
const label = getLabel(item)
|
|
const match = label?.includes(cond)
|
|
|
|
if (item.children?.length) {
|
|
item.children = mapFilter(item.children, cond)
|
|
}
|
|
|
|
if (match || item.children?.length) {
|
|
nItems.push(item)
|
|
}
|
|
})
|
|
return nItems
|
|
}
|
|
|
|
return (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(status) => {
|
|
setOpen(status)
|
|
if (status) {
|
|
setFiltered(props.options)
|
|
setFilter('')
|
|
}
|
|
}}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
theme="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={merge(
|
|
`flex justify-between`,
|
|
props.className,
|
|
)}
|
|
>
|
|
{label.length
|
|
? <span className="text-sm">{label.join('/')}</span>
|
|
: <span className="text-sm text-weak">{props.placeholder}</span>
|
|
}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0 rounded-lg w-[var(--radix-popover-trigger-width)] h-[var(--radix-popover-content-available-height)] flex flex-col overflow-hidden"
|
|
align="start"
|
|
collisionPadding={6}
|
|
>
|
|
<div className="p-2 flex gap-2 flex-none">
|
|
<Input
|
|
className="h-9 placeholder:text-weak placeholder:text-sm"
|
|
placeholder="搜索地区"
|
|
value={filter}
|
|
onChange={event => setFilter(event.target.value)}
|
|
/>
|
|
<Button className="h-9" onClick={onFilter} disabled={wait}>
|
|
搜索
|
|
</Button>
|
|
</div>
|
|
<div className="flex-auto overflow-auto p-2 pt-0">
|
|
<OptionList
|
|
options={filtered}
|
|
value={values}
|
|
onChange={(value) => {
|
|
console.log(value.map(item => item.value))
|
|
props.onChange?.(value.map(item => item.value))
|
|
setOpen(false)
|
|
}}/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function OptionList(props: {
|
|
options: ComboboxItem[]
|
|
value: string[]
|
|
onChange: (value: ComboboxItem[]) => void
|
|
path?: ComboboxItem[]
|
|
depth?: number
|
|
}) {
|
|
const depth = props.depth || 0
|
|
const parent = props.path || []
|
|
const indent = depth * 16
|
|
|
|
return (
|
|
<ul style={{
|
|
marginLeft: `${indent}px`,
|
|
}}>
|
|
{props.options.map((item, i) => {
|
|
const path = [...parent, item]
|
|
const pathValue = path.map(item => item.value)
|
|
const equal = pathValue.join(`.`) === props.value?.join('.')
|
|
return (
|
|
<li key={i}>
|
|
<OptionItem key={`${i}`} item={item} active={equal} onChange={() => props.onChange?.(path)}/>
|
|
{item.children?.length
|
|
&& <OptionList depth={depth + 1} options={item.children} value={props.value} path={path} onChange={props.onChange}/>
|
|
}
|
|
</li>
|
|
)
|
|
})}
|
|
</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>
|
|
)
|
|
}
|