115 lines
3.2 KiB
TypeScript
115 lines
3.2 KiB
TypeScript
// admin-spa/src/components/ui/searchable-select.tsx
|
|
import * as React from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandInput,
|
|
CommandList,
|
|
CommandItem,
|
|
CommandEmpty,
|
|
} from "@/components/ui/command";
|
|
import { Check, ChevronsUpDown } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface Option {
|
|
value: string;
|
|
/** What to render in the button/list. Can be a string or React node. */
|
|
label: React.ReactNode;
|
|
/** Optional text used for filtering. Falls back to string label or value. */
|
|
triggerLabel?: React.ReactNode;
|
|
}
|
|
|
|
interface Props {
|
|
value?: string;
|
|
onChange?: (v: string) => void;
|
|
options: Option[];
|
|
placeholder?: string;
|
|
emptyLabel?: string;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
search?: string;
|
|
onSearch?: (v: string) => void;
|
|
showCheckIndicator?: boolean;
|
|
}
|
|
|
|
export function SearchableSelect({
|
|
value,
|
|
onChange,
|
|
options,
|
|
placeholder = "Select...",
|
|
emptyLabel = "No results found.",
|
|
className,
|
|
disabled = false,
|
|
search,
|
|
onSearch,
|
|
showCheckIndicator = true,
|
|
}: Props) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const selected = options.find((o) => o.value === value);
|
|
|
|
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
|
|
|
return (
|
|
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className={cn("w-full justify-between", className)}
|
|
disabled={disabled}
|
|
aria-disabled={disabled}
|
|
tabIndex={disabled ? -1 : 0}
|
|
>
|
|
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
|
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0 w-[--radix-popover-trigger-width]"
|
|
align="start"
|
|
sideOffset={4}
|
|
>
|
|
<Command shouldFilter>
|
|
<CommandInput
|
|
className="command-palette-search"
|
|
placeholder="Search..."
|
|
value={search}
|
|
onValueChange={onSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>{emptyLabel}</CommandEmpty>
|
|
{options.map((opt) => (
|
|
<CommandItem
|
|
key={opt.value}
|
|
value={
|
|
typeof opt.searchText === 'string' && opt.searchText.length > 0
|
|
? opt.searchText
|
|
: (typeof opt.label === 'string' ? opt.label : opt.value)
|
|
}
|
|
onSelect={() => {
|
|
onChange?.(opt.value);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{showCheckIndicator && (
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4 flex-shrink-0",
|
|
opt.value === value ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
)}
|
|
{opt.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
} |