ema-frontend-demo / src /components /ui /SearchableSelect.tsx
harryagasi
feat: update chat components and add SearchableSelect UI component
13cde9d
Raw
History Blame Contribute Delete
4.1 kB
"use client";
import { useState, useRef, useEffect } from "react";
import { ChevronDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchableSelectProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
}
export function SearchableSelect({
label,
value,
onChange,
options,
disabled,
className,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
useEffect(() => {
if (open) {
setQuery("");
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
return (
<div className={cn("flex flex-col gap-1", className)} ref={containerRef}>
{label && (
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wide">
{label}
</label>
)}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"w-full flex items-center justify-between rounded-md border border-neutral-200 bg-white pl-2.5 pr-7 py-1 text-xs text-neutral-800 text-left",
"transition-all duration-200 outline-none cursor-pointer",
"focus:border-brand-green focus:ring-2 focus:ring-brand-green/10",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<span className="truncate">{selectedLabel}</span>
</button>
<ChevronDown
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-neutral-400 pointer-events-none transition-transform",
open && "rotate-180"
)}
/>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg">
<div className="flex items-center gap-1.5 border-b border-neutral-100 px-2 py-1.5">
<Search className="h-3 w-3 text-neutral-400 shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Cari..."
className="w-full text-xs outline-none placeholder:text-neutral-400"
/>
</div>
<ul className="max-h-48 overflow-y-auto py-1">
{filtered.length === 0 ? (
<li className="px-3 py-1.5 text-xs text-neutral-400">Tidak ditemukan</li>
) : (
filtered.map((opt) => (
<li
key={opt.value}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={cn(
"px-3 py-1.5 text-xs cursor-pointer hover:bg-neutral-50",
opt.value === value && "font-medium text-brand-green bg-brand-green/5"
)}
>
{opt.label}
</li>
))
)}
</ul>
</div>
)}
</div>
</div>
);
}