198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState, useId, type ReactNode } from "react"
|
|
import { ChevronsUpDown, Check, X } from "lucide-react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type SearchableComboboxOption = {
|
|
value: string
|
|
label: string
|
|
description?: string
|
|
keywords?: string[]
|
|
disabled?: boolean
|
|
}
|
|
|
|
type SearchableComboboxProps = {
|
|
value: string | null
|
|
onValueChange: (value: string | null) => void
|
|
options: SearchableComboboxOption[]
|
|
placeholder?: string
|
|
searchPlaceholder?: string
|
|
emptyText?: string
|
|
className?: string
|
|
triggerClassName?: string
|
|
disabled?: boolean
|
|
allowClear?: boolean
|
|
clearLabel?: string
|
|
renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
|
|
renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
|
|
contentClassName?: string
|
|
scrollClassName?: string
|
|
scrollProps?: React.HTMLAttributes<HTMLDivElement>
|
|
prefix?: ReactNode
|
|
}
|
|
|
|
export function SearchableCombobox({
|
|
value,
|
|
onValueChange,
|
|
options,
|
|
placeholder = "Selecionar...",
|
|
searchPlaceholder = "Buscar...",
|
|
emptyText = "Nenhuma opção encontrada.",
|
|
className,
|
|
triggerClassName,
|
|
disabled,
|
|
allowClear = false,
|
|
clearLabel = "Limpar seleção",
|
|
renderValue,
|
|
renderOption,
|
|
contentClassName,
|
|
scrollClassName,
|
|
scrollProps,
|
|
prefix,
|
|
}: SearchableComboboxProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [search, setSearch] = useState("")
|
|
const listId = useId()
|
|
|
|
const selected = useMemo(() => {
|
|
if (value === null) return null
|
|
return options.find((option) => option.value === value) ?? null
|
|
}, [options, value])
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = search.trim().toLowerCase()
|
|
if (!term) {
|
|
return options
|
|
}
|
|
return options.filter((option) => {
|
|
const labelMatch = option.label.toLowerCase().includes(term)
|
|
const descriptionMatch = option.description?.toLowerCase().includes(term) ?? false
|
|
const keywordMatch = option.keywords?.some((keyword) => keyword.toLowerCase().includes(term)) ?? false
|
|
return labelMatch || descriptionMatch || keywordMatch
|
|
})
|
|
}, [options, search])
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSearch("")
|
|
}
|
|
}, [open])
|
|
|
|
const handleSelect = (nextValue: string) => {
|
|
if (nextValue === value) {
|
|
setOpen(false)
|
|
return
|
|
}
|
|
onValueChange(nextValue)
|
|
setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"flex h-full w-full items-center justify-between gap-2 rounded-full border border-input bg-background px-3 text-sm font-semibold text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
|
|
className,
|
|
triggerClassName,
|
|
)}
|
|
>
|
|
<span className="flex flex-1 items-center gap-2">
|
|
{prefix ? <span className="inline-flex items-center text-neutral-400">{prefix}</span> : null}
|
|
<span className="flex-1 truncate text-left">
|
|
{renderValue ? (
|
|
renderValue(selected)
|
|
) : selected?.label ? (
|
|
selected.label
|
|
) : (
|
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 size-4 shrink-0 text-neutral-500 opacity-70" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
id={listId}
|
|
className={cn("z-50 w-[var(--radix-popover-trigger-width)] max-w-[480px] p-0", contentClassName)}
|
|
>
|
|
<div className="border-b border-border/80 p-2">
|
|
<Input
|
|
autoFocus
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
{allowClear ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onValueChange(null)
|
|
setOpen(false)
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center justify-between gap-2 px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted",
|
|
selected === null ? "text-foreground" : "",
|
|
)}
|
|
>
|
|
<span>{clearLabel}</span>
|
|
<X className="size-4 opacity-60" />
|
|
</button>
|
|
<Separator />
|
|
</>
|
|
) : null}
|
|
<ScrollArea
|
|
className={cn("max-h-60", scrollClassName)}
|
|
{...scrollProps}
|
|
>
|
|
{filtered.length === 0 ? (
|
|
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyText}</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{filtered.map((option) => {
|
|
const isActive = option.value === value
|
|
const content = renderOption ? (
|
|
renderOption(option, isActive)
|
|
) : (
|
|
<div className="flex flex-col">
|
|
<span className="font-medium text-foreground">{option.label}</span>
|
|
{option.description ? <span className="text-xs text-muted-foreground">{option.description}</span> : null}
|
|
</div>
|
|
)
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={option.value}
|
|
disabled={option.disabled}
|
|
onClick={() => handleSelect(option.value)}
|
|
className={cn(
|
|
"flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition",
|
|
option.disabled ? "cursor-not-allowed opacity-50" : "hover:bg-muted",
|
|
isActive ? "bg-muted" : "",
|
|
)}
|
|
>
|
|
{content}
|
|
<Check className={cn("size-4 shrink-0 text-primary", isActive ? "opacity-100" : "opacity-0")} />
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|