sistema-de-chamados/src/components/ui/searchable-combobox.tsx
2025-11-13 21:08:34 -03:00

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>
)
}