SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.1 KB · 116 lines tsx
Raw Blame History
1"use client";23import type { SearchHit, SearchResponse } from "@dci/core";4import { Plus, X } from "lucide-react";5import { useRouter } from "next/navigation";6import { useEffect, useRef, useState } from "react";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { SearchInput } from "@/components/ui/search-input";10import { apiGet, clientApiBase } from "@/lib/api";11import { useDebounced } from "@/lib/hooks";12import { routes } from "@/lib/routes";1314export const MAX_OPERATORS = 5;1516/**17 * Pick 2–5 operators: search /search?type=operator, chips for the current selection, URL is the state18 * (`?slugs=a,b,c`, router.replace so the server re-renders the comparison).19 */20export function OperatorPicker({ slugs, names }: { slugs: string[]; names: Record<string, string> }) {21  const router = useRouter();22  const [q, setQ] = useState("");23  const dq = useDebounced(q.trim(), 200);24  // results keyed by the query they answer: hits for another query are never shown (derived, no setState-in-effect)25  const [result, setResult] = useState<{ q: string; hits: SearchHit[] }>({ q: "", hits: [] });26  const hits = result.q === dq ? result.hits.filter((h) => !slugs.includes(h.slug)) : [];27  const loading = dq.length >= 2 && result.q !== dq;28  const [open, setOpen] = useState(false);29  const boxRef = useRef<HTMLDivElement>(null);3031  useEffect(() => {32    if (dq.length < 2) return;33    const ctrl = new AbortController();34    void apiGet<SearchResponse>("/search", { q: dq, type: "operator" }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 6000 }).then((r) => {35      if (ctrl.signal.aborted) return;36      setResult({ q: dq, hits: r.ok ? r.data.hits.filter((h) => h.type === "operator").slice(0, 10) : [] });37    });38    return () => ctrl.abort();39  }, [dq]);4041  useEffect(() => {42    const onDoc = (e: MouseEvent) => {43      if (!boxRef.current?.contains(e.target as Node)) setOpen(false);44    };45    document.addEventListener("mousedown", onDoc);46    return () => document.removeEventListener("mousedown", onDoc);47  }, []);4849  const apply = (next: string[]) => {50    router.replace(routes.compareOperators(next), { scroll: false });51  };52  const add = (slug: string) => {53    if (slugs.includes(slug) || slugs.length >= MAX_OPERATORS) return;54    setQ("");55    setOpen(false);56    apply([...slugs, slug]);57  };58  const remove = (slug: string) => apply(slugs.filter((s) => s !== slug));59  const full = slugs.length >= MAX_OPERATORS;6061  return (62    <div className="flex flex-col gap-2">63      <div className="flex flex-wrap items-center gap-1.5">64        {slugs.map((s) => (65          <span key={s} className="inline-flex h-10 items-center gap-1 rounded-sm border border-ink bg-ink pl-2.5 pr-1 text-[12.5px] font-medium text-paper sm:h-8">66            {names[s] ?? s}67            <button type="button" onClick={() => remove(s)} aria-label={`Remove ${names[s] ?? s}`} className="inline-flex size-10 items-center justify-center rounded-xs hover:bg-paper/15 sm:size-6">68              <X className="size-3.5" />69            </button>70          </span>71        ))}72        {slugs.length > 0 && (73          <Button size="xs" variant="ghost" onClick={() => apply([])}>74            Clear75          </Button>76        )}77      </div>78      <div ref={boxRef} className="relative max-w-md">79        <SearchInput80          value={q}81          onChange={(v) => {82            setQ(v);83            setOpen(true);84          }}85          onFocus={() => setOpen(true)}86          placeholder={full ? `Maximum ${MAX_OPERATORS} operators` : slugs.length ? "Add an operator…" : "Search an operator (e.g. Equinix)"}87          disabled={full}88          loading={loading}89          aria-label="Search operators to compare"90          aria-expanded={open && hits.length > 0}91          aria-controls="compare-operator-results"92          role="combobox"93        />94        {open && dq.length >= 2 && (95          <ul id="compare-operator-results" role="listbox" className="absolute left-0 right-0 top-full z-[20] mt-1 max-h-72 overflow-y-auto rounded-sm border border-hair-2 bg-surface py-1">96            {hits.length === 0 && !loading && <li className="px-3 py-2 text-[12.5px] text-ink-3">No operator matches “{dq}”.</li>}97            {hits.map((h) => (98              <li key={h.id} role="option" aria-selected={false}>99                <button type="button" onClick={() => add(h.slug)} className={cn("flex min-h-10 w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] hover:bg-surface-2")}>100                  <Plus className="size-3.5 shrink-0 text-ink-3" aria-hidden />101                  <span className="min-w-0 flex-1 truncate text-ink">{h.title}</span>102                  {h.subtitle && <span className="shrink-0 truncate text-[11.5px] text-ink-3">{h.subtitle}</span>}103                </button>104              </li>105            ))}106          </ul>107        )}108      </div>109      <p className="text-[11.5px] text-ink-4">110        {slugs.length < 2 ? `Pick at least 2 operators (up to ${MAX_OPERATORS}). ` : `${slugs.length} of ${MAX_OPERATORS} operators. `}111        The URL holds the selection — copy it to share the comparison.112      </p>113    </div>114  );115}116