97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Check, ChevronDown, Search } from "lucide-react";
|
|
|
|
/** Select con búsqueda (autocomplete) accesible y ligero. */
|
|
export function BuscadorSelect({
|
|
opciones,
|
|
valor,
|
|
onChange,
|
|
placeholder = "Selecciona una opción",
|
|
buscarPlaceholder = "Buscar lugar de cita…",
|
|
cargando = false,
|
|
vacioTexto = "Sin resultados",
|
|
id,
|
|
}: {
|
|
opciones: string[];
|
|
valor: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
buscarPlaceholder?: string;
|
|
cargando?: boolean;
|
|
vacioTexto?: string;
|
|
id?: string;
|
|
}) {
|
|
const [abierto, setAbierto] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const fuera = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setAbierto(false);
|
|
};
|
|
document.addEventListener("mousedown", fuera);
|
|
return () => document.removeEventListener("mousedown", fuera);
|
|
}, []);
|
|
|
|
const filtradas = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return opciones;
|
|
return opciones.filter((o) => o.toLowerCase().includes(q));
|
|
}, [opciones, query]);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
id={id}
|
|
type="button"
|
|
onClick={() => setAbierto((v) => !v)}
|
|
className="flex w-full items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-left text-sm transition-all hover:border-primary/60 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<span className={valor ? "truncate font-medium" : "truncate text-muted-foreground"}>
|
|
{valor || placeholder}
|
|
</span>
|
|
<ChevronDown
|
|
className={`size-4 shrink-0 text-muted-foreground transition-transform duration-300 ${abierto ? "rotate-180" : ""}`}
|
|
/>
|
|
</button>
|
|
|
|
{abierto && (
|
|
<div className="absolute z-40 mt-2 w-full overflow-hidden rounded-2xl border border-border bg-popover shadow-lift animate-in fade-in-0 zoom-in-95">
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
|
<Search className="size-4 text-muted-foreground" />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={buscarPlaceholder}
|
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
<ul className="max-h-64 overflow-y-auto py-1">
|
|
{cargando && <li className="px-4 py-3 text-sm text-muted-foreground">Cargando…</li>}
|
|
{!cargando && filtradas.length === 0 && (
|
|
<li className="px-4 py-3 text-sm text-muted-foreground">{vacioTexto}</li>
|
|
)}
|
|
{filtradas.map((o) => (
|
|
<li key={o}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange(o);
|
|
setAbierto(false);
|
|
setQuery("");
|
|
}}
|
|
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent"
|
|
>
|
|
<span className="truncate">{o}</span>
|
|
{valor === o && <Check className="size-4 shrink-0 text-primary" />}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|