220 lines
6.7 KiB
TypeScript
220 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { Check, ChevronDown } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
|
|
export type DropdownSelectOption = {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
icon?: React.ElementType<{ className?: string }>;
|
|
iconClassName?: string;
|
|
};
|
|
|
|
type DropdownSelectProps = {
|
|
value?: string;
|
|
onValueChange: (value: string) => void;
|
|
options: DropdownSelectOption[];
|
|
placeholder?: string;
|
|
ariaLabel: string;
|
|
disabled?: boolean;
|
|
triggerClassName?: string;
|
|
contentClassName?: string;
|
|
itemClassName?: string;
|
|
searchEnabled?: boolean;
|
|
searchPlaceholder?: string;
|
|
emptyMessage?: string;
|
|
};
|
|
|
|
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
|
if (placeholder) {
|
|
return placeholder;
|
|
}
|
|
const trimmed = ariaLabel.trim();
|
|
if (!trimmed) {
|
|
return "Select an option";
|
|
}
|
|
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
|
};
|
|
|
|
const resolveSearchPlaceholder = (
|
|
ariaLabel: string,
|
|
searchPlaceholder?: string,
|
|
) => {
|
|
if (searchPlaceholder) {
|
|
return searchPlaceholder;
|
|
}
|
|
const cleaned = ariaLabel.replace(/^select\\s+/i, "").trim();
|
|
if (!cleaned) {
|
|
return "Search...";
|
|
}
|
|
const value = `Search ${cleaned}`;
|
|
return value.endsWith("...") ? value : `${value}...`;
|
|
};
|
|
|
|
export default function DropdownSelect({
|
|
value,
|
|
onValueChange,
|
|
options,
|
|
placeholder,
|
|
ariaLabel,
|
|
disabled = false,
|
|
triggerClassName,
|
|
contentClassName,
|
|
itemClassName,
|
|
searchEnabled = true,
|
|
searchPlaceholder,
|
|
emptyMessage,
|
|
}: DropdownSelectProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [searchValue, setSearchValue] = React.useState("");
|
|
const listRef = React.useRef<HTMLDivElement | null>(null);
|
|
const selectedOption = options.find((option) => option.value === value);
|
|
const resolvedPlaceholder = resolvePlaceholder(ariaLabel, placeholder);
|
|
const triggerLabel = selectedOption?.label ?? value ?? "";
|
|
const SelectedIcon = selectedOption?.icon;
|
|
const selectedIconClassName = selectedOption?.iconClassName;
|
|
const showPlaceholder = !triggerLabel;
|
|
const resolvedSearchPlaceholder = searchEnabled
|
|
? resolveSearchPlaceholder(ariaLabel, searchPlaceholder)
|
|
: "";
|
|
|
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
setOpen(nextOpen);
|
|
if (!nextOpen) {
|
|
setSearchValue("");
|
|
}
|
|
};
|
|
|
|
const handleSelect = (nextValue: string) => {
|
|
if (nextValue !== value) {
|
|
onValueChange(nextValue);
|
|
}
|
|
handleOpenChange(false);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
if (listRef.current) {
|
|
listRef.current.scrollTop = 0;
|
|
}
|
|
}, [open, searchValue]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
disabled={disabled}
|
|
className={cn(
|
|
"inline-flex h-10 w-auto cursor-pointer items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
|
open && "bg-slate-50",
|
|
triggerClassName,
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-2 truncate",
|
|
showPlaceholder && "text-slate-500",
|
|
)}
|
|
>
|
|
{SelectedIcon ? (
|
|
<SelectedIcon
|
|
className={cn("h-4 w-4 text-slate-600", selectedIconClassName)}
|
|
/>
|
|
) : null}
|
|
<span className="truncate">
|
|
{showPlaceholder ? resolvedPlaceholder : triggerLabel}
|
|
</span>
|
|
</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 shrink-0 text-slate-400 transition-transform",
|
|
open && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
sideOffset={6}
|
|
className={cn(
|
|
"w-[var(--radix-popover-trigger-width)] min-w-[12rem] overflow-hidden rounded-md border border-slate-200 bg-white p-0 text-slate-900 shadow-lg",
|
|
contentClassName,
|
|
)}
|
|
>
|
|
<Command label={ariaLabel} className="w-full">
|
|
{searchEnabled ? (
|
|
<CommandInput
|
|
value={searchValue}
|
|
onValueChange={setSearchValue}
|
|
placeholder={resolvedSearchPlaceholder}
|
|
autoFocus
|
|
className="text-sm"
|
|
/>
|
|
) : null}
|
|
<CommandList ref={listRef} className="max-h-64 p-1">
|
|
<CommandEmpty className="px-3 py-6 text-center text-sm text-slate-500">
|
|
{emptyMessage ?? "No results found."}
|
|
</CommandEmpty>
|
|
{options.map((option) => {
|
|
const isSelected = value === option.value;
|
|
const OptionIcon = option.icon;
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
keywords={[option.label, option.value]}
|
|
disabled={option.disabled}
|
|
onSelect={handleSelect}
|
|
className={cn(
|
|
"flex items-center justify-between gap-2 rounded-lg px-4 py-3 text-sm text-gray-700 transition-colors data-[selected=true]:bg-gray-50 data-[selected=true]:text-gray-900",
|
|
isSelected && "font-semibold",
|
|
!isSelected && "hover:bg-gray-50",
|
|
itemClassName,
|
|
)}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{OptionIcon ? (
|
|
<OptionIcon
|
|
className={cn(
|
|
"h-4 w-4",
|
|
isSelected ? "text-gray-700" : "text-gray-500",
|
|
option.iconClassName,
|
|
)}
|
|
/>
|
|
) : null}
|
|
<span className="truncate font-medium">{option.label}</span>
|
|
</span>
|
|
{isSelected ? (
|
|
<Check className="h-4 w-4 text-gray-400" />
|
|
) : null}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|