Spaces:
Sleeping
Sleeping
| "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> | |
| ); | |
| } | |