feat: improve requester combobox and admin cleanup flows
This commit is contained in:
parent
788f6928a1
commit
37c32149a6
13 changed files with 923 additions and 180 deletions
174
src/components/ui/searchable-combobox.tsx
Normal file
174
src/components/ui/searchable-combobox.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { ChevronsUpDown, Check, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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
|
||||
disabled?: boolean
|
||||
allowClear?: boolean
|
||||
clearLabel?: string
|
||||
renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
|
||||
renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchableCombobox({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Selecionar...",
|
||||
searchPlaceholder = "Buscar...",
|
||||
emptyText = "Nenhuma opção encontrada.",
|
||||
className,
|
||||
disabled,
|
||||
allowClear = false,
|
||||
clearLabel = "Limpar seleção",
|
||||
renderValue,
|
||||
renderOption,
|
||||
}: SearchableComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
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"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-full border border-input bg-background px-3 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{renderValue ? renderValue(selected) : selected?.label ?? <span className="text-muted-foreground">{placeholder}</span>}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-50 w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<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="max-h-60">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue