spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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