globals.css define input:focus-visible { outline: 3px solid
rgb(0 85 184 / .5); ... } como indicador de foco por teclado global,
intencional (no se toca). Le gana en especificidad CSS a la utilidad
outline-none de Tailwind, asi que seguia mostrandose sobre el input
propio del buscador (commit anterior) -- pero como ese input no tenia
border-radius, se veia como un rectangulo de esquinas rectas
superpuesto al pill redondeado, reportado en produccion (v1.0.192).
Agrega rounded-full al input para que el mismo outline (que se debe
conservar) siga el contorno del pill en vez de cortarlo.
101 lines
4.7 KiB
TypeScript
101 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { Search, X } from "lucide-react";
|
|
import { FormEvent, useEffect, useState } from "react";
|
|
import type { Product } from "@/lib/headless/types";
|
|
|
|
export function PredictiveSearch() {
|
|
const router = useRouter();
|
|
const [query, setQuery] = useState("");
|
|
const [matches, setMatches] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const normalized = query.trim();
|
|
|
|
useEffect(() => {
|
|
if (normalized.length < 2) {
|
|
setMatches([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
const timer = setTimeout(() => {
|
|
fetch(`/api/search?q=${encodeURIComponent(normalized)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((response) => response.json())
|
|
.then((data: { products?: Product[] }) => {
|
|
setMatches((data.products ?? []).slice(0, 6));
|
|
})
|
|
.catch((error) => {
|
|
if ((error as Error).name !== "AbortError") setMatches([]);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, 250);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [normalized]);
|
|
|
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const value = query.trim();
|
|
if (!value) return;
|
|
router.push(`/catalog?q=${encodeURIComponent(value)}`);
|
|
setQuery("");
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={submit} className="relative w-full">
|
|
<div className="flex items-center rounded-full border-2 border-slate-200 bg-white pl-5 pr-1.5 shadow-sm transition focus-within:border-ari-marino focus-within:ring-4 focus-within:ring-ari-coral-claro/30">
|
|
{/* input propio (no el <Input> compartido) -- ese componente trae su
|
|
propio focus:ring-4 focus:ring-cyan-300/30 hardcodeado que no se
|
|
anulaba de forma confiable via tailwind-merge dentro de este
|
|
contenedor compuesto (input + boton circular), causando un
|
|
segundo ring visible al hacer foco. Mas simple y robusto no
|
|
heredar clases de foco que hay que pelear despues.
|
|
|
|
Sin outline-none: globals.css define un outline de
|
|
accesibilidad global para foco por teclado
|
|
(input:focus-visible, azul de marca) que le gana en
|
|
especificidad a la utilidad outline-none de Tailwind -- no
|
|
se debe quitar, es intencional. Se le agrega rounded-full
|
|
aqui para que ese outline seguido el contorno del pill en
|
|
vez de dibujarse como rectangulo recto. */}
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Busca juguetes, mochilas, personajes…"
|
|
className="h-[48px] w-full rounded-full border-0 bg-transparent p-0 text-sm text-slate-950"
|
|
/>
|
|
{query && <button type="button" aria-label="Limpiar búsqueda" className="mr-1 shrink-0 rounded-full p-1.5 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
|
<button type="submit" aria-label="Buscar" className="grid size-10 shrink-0 place-items-center rounded-full bg-ari-marino text-white transition hover:brightness-110">
|
|
<Search className="size-4.5" />
|
|
</button>
|
|
</div>
|
|
{loading && normalized.length >= 2 && matches.length === 0 && (
|
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-4 text-center text-sm text-slate-500 shadow-2xl">
|
|
Buscando…
|
|
</div>
|
|
)}
|
|
{matches.length > 0 && (
|
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
|
{matches.map((product) => (
|
|
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-ari-marino/5" onClick={() => setQuery("")}>
|
|
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
|
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
|
</Link>
|
|
))}
|
|
<button type="submit" className="mt-1 w-full rounded-xl bg-ari-marino px-4 py-3 text-sm font-black text-white hover:brightness-110">Buscar “{query.trim()}” en todo el catálogo</button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|