126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { Check, ChevronDown, Search } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
|
|
interface ComboboxOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
interface ComboboxProps {
|
|
options: ComboboxOption[]
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder?: string
|
|
searchPlaceholder?: string
|
|
emptyMessage?: string
|
|
disabled?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = 'Select...',
|
|
searchPlaceholder = 'Search...',
|
|
emptyMessage = 'No results found.',
|
|
disabled = false,
|
|
className,
|
|
}: ComboboxProps) {
|
|
const [open, setOpen] = React.useState(false)
|
|
const [search, setSearch] = React.useState('')
|
|
|
|
const selectedLabel = options.find((o) => o.value === value)?.label
|
|
|
|
const filtered = React.useMemo(() => {
|
|
if (!search.trim()) return options
|
|
const q = search.toLowerCase()
|
|
return options.filter(
|
|
(o) =>
|
|
o.label.toLowerCase().includes(q) ||
|
|
o.value.toLowerCase().includes(q)
|
|
)
|
|
}, [options, search])
|
|
|
|
const handleSelect = (optionValue: string) => {
|
|
onChange(optionValue === value ? '' : optionValue)
|
|
setOpen(false)
|
|
setSearch('')
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
!value && 'text-muted-foreground',
|
|
className
|
|
)}
|
|
>
|
|
<span className="truncate">{selectedLabel || placeholder}</span>
|
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
<div className="flex items-center border-b px-3">
|
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
<input
|
|
className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="max-h-60 overflow-y-auto p-1">
|
|
{filtered.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
filtered.map((option) => {
|
|
const isSelected = option.value === value
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleSelect(option.value)}
|
|
className={cn(
|
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
|
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
|
isSelected && 'bg-accent'
|
|
)}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 h-4 w-4 shrink-0',
|
|
isSelected ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|