"use client"; import type { SearchHit, SearchResponse } from "@dci/core"; import { Plus, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { SearchInput } from "@/components/ui/search-input"; import { apiGet, clientApiBase } from "@/lib/api"; import { useDebounced } from "@/lib/hooks"; import { routes } from "@/lib/routes"; export const MAX_OPERATORS = 5; /** * Pick 2–5 operators: search /search?type=operator, chips for the current selection, URL is the state * (`?slugs=a,b,c`, router.replace so the server re-renders the comparison). */ export function OperatorPicker({ slugs, names }: { slugs: string[]; names: Record }) { const router = useRouter(); const [q, setQ] = useState(""); const dq = useDebounced(q.trim(), 200); // results keyed by the query they answer: hits for another query are never shown (derived, no setState-in-effect) const [result, setResult] = useState<{ q: string; hits: SearchHit[] }>({ q: "", hits: [] }); const hits = result.q === dq ? result.hits.filter((h) => !slugs.includes(h.slug)) : []; const loading = dq.length >= 2 && result.q !== dq; const [open, setOpen] = useState(false); const boxRef = useRef(null); useEffect(() => { if (dq.length < 2) return; const ctrl = new AbortController(); void apiGet("/search", { q: dq, type: "operator" }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 6000 }).then((r) => { if (ctrl.signal.aborted) return; setResult({ q: dq, hits: r.ok ? r.data.hits.filter((h) => h.type === "operator").slice(0, 10) : [] }); }); return () => ctrl.abort(); }, [dq]); useEffect(() => { const onDoc = (e: MouseEvent) => { if (!boxRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); const apply = (next: string[]) => { router.replace(routes.compareOperators(next), { scroll: false }); }; const add = (slug: string) => { if (slugs.includes(slug) || slugs.length >= MAX_OPERATORS) return; setQ(""); setOpen(false); apply([...slugs, slug]); }; const remove = (slug: string) => apply(slugs.filter((s) => s !== slug)); const full = slugs.length >= MAX_OPERATORS; return (
{slugs.map((s) => ( {names[s] ?? s} ))} {slugs.length > 0 && ( )}
{ setQ(v); setOpen(true); }} onFocus={() => setOpen(true)} placeholder={full ? `Maximum ${MAX_OPERATORS} operators` : slugs.length ? "Add an operator…" : "Search an operator (e.g. Equinix)"} disabled={full} loading={loading} aria-label="Search operators to compare" aria-expanded={open && hits.length > 0} aria-controls="compare-operator-results" role="combobox" /> {open && dq.length >= 2 && (
    {hits.length === 0 && !loading &&
  • No operator matches “{dq}”.
  • } {hits.map((h) => (
  • ))}
)}

{slugs.length < 2 ? `Pick at least 2 operators (up to ${MAX_OPERATORS}). ` : `${slugs.length} of ${MAX_OPERATORS} operators. `} The URL holds the selection — copy it to share the comparison.

); }