Files
apps-registry/workloads/ecommerce/components/search/predictive-search.tsx
T
devops 75dedc4e67 fix(ecommerce): outline de accesibilidad del buscador sigue el pill
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.
2026-08-28 05:53:15 +00:00

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>
);
}