SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
10.2 KB · 251 lines tsx
Raw Blame History
1'use client';2import { ArrowRight, Building2, Clock, CornerDownLeft, Factory, Globe2, Search, Tag, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useEffect, useRef, useState } from 'react';6import { clientApi } from '@/lib/client-api';7import { cn } from '@/lib/cn';8import { EXAMPLE_QUERIES, primaryNav, routes } from '@/lib/site';9import type { Suggestion } from '@/lib/types';10import { useSearch } from './search-context';1112const RECENT_KEY = 'ca-recent';13const RECENT_MAX = 6;14function readRecent(): Suggestion[] {15  try {16    const arr = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as unknown;17    return Array.isArray(arr) ? (arr.filter((x) => x && typeof (x as Suggestion).href === 'string') as Suggestion[]).slice(0, RECENT_MAX) : [];18  } catch {19    return [];20  }21}22function pushRecent(s: Suggestion) {23  try {24    localStorage.setItem(RECENT_KEY, JSON.stringify([s, ...readRecent().filter((r) => r.href !== s.href)].slice(0, RECENT_MAX)));25  } catch {26    /* ignore */27  }28}2930const ICON = { company: Building2, industry: Factory, country: Globe2, event_type: Tag } as const;3132type Row = { key: string; href: string; node: React.ReactNode; section: string; run?: () => void };3334/**35 * ⌘K search: `/search/suggest` (debounced 120 ms) grouped by kind, recent picks, example natural-language queries and a36 * "Search everything" row. Keyboard: ↑↓ move · ↵ open · esc close. Mobile: full-screen sheet.37 */38export function SearchDialog() {39  const { open, setOpen } = useSearch();40  const router = useRouter();41  const [q, setQ] = useState('');42  const [items, setItems] = useState<Suggestion[]>([]);43  const [active, setActive] = useState(0);44  const [loading, setLoading] = useState(false);45  const [failed, setFailed] = useState(false);46  const [recent, setRecent] = useState<Suggestion[]>([]);47  const inputRef = useRef<HTMLInputElement>(null);48  const listRef = useRef<HTMLUListElement>(null);49  const term = q.trim();5051  useEffect(() => {52    listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });53  }, [active]);5455  useEffect(() => {56    if (open) {57      setTimeout(() => inputRef.current?.focus(), 20);58      document.body.style.overflow = 'hidden';59      setRecent(readRecent());60      setActive(0);61    } else {62      document.body.style.overflow = '';63      setQ('');64      setItems([]);65      setFailed(false);66    }67    return () => {68      document.body.style.overflow = '';69    };70  }, [open]);7172  useEffect(() => {73    if (!open || term.length < 1) {74      setItems([]);75      return;76    }77    const ctrl = new AbortController();78    const t = setTimeout(async () => {79      setLoading(true);80      try {81        const res = await clientApi.suggest(term, ctrl.signal);82        setItems(res.items ?? []);83        setFailed(false);84        setActive(0);85      } catch (e) {86        if ((e as Error).name !== 'AbortError') setFailed(true);87      } finally {88        setLoading(false);89      }90    }, 120);91    return () => {92      clearTimeout(t);93      ctrl.abort();94    };95  }, [term, open]);9697  const close = useCallback(() => setOpen(false), [setOpen]);98  const go = useCallback(99    (href: string) => {100      router.push(href);101      close();102    },103    [router, close],104  );105106  if (!open) return null;107108  const rows: Row[] = [];109  const node = (s: Suggestion, icon?: React.ReactNode) => {110    const I = ICON[s.kind] ?? Tag;111    return (112      <>113        {icon ?? <I className="size-4 shrink-0 text-ink-3" aria-hidden />}114        <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.label}</span>115        {s.sublabel && <span className="hidden max-w-[40%] truncate text-xs text-ink-3 sm:block">{s.sublabel}</span>}116        <span className="text-[10px] uppercase tracking-wider text-ink-3">{s.kind.replace('_', ' ')}</span>117      </>118    );119  };120  const order: Suggestion['kind'][] = ['company', 'industry', 'country', 'event_type'];121  for (const k of order) for (const s of items.filter((i) => i.kind === k)) rows.push({ key: `s-${s.href}`, href: s.href, section: k === 'company' ? 'Companies' : k === 'industry' ? 'Industries' : k === 'country' ? 'Countries' : 'Event types', node: node(s), run: () => pushRecent(s) });122  if (!term && recent.length) for (const r of recent) rows.push({ key: `r-${r.href}`, href: r.href, section: 'Recent', node: node(r, <Clock className="size-4 shrink-0 text-ink-3" aria-hidden />) });123  if (term)124    rows.push({125      key: '__all',126      href: routes.search(term),127      section: 'Search',128      node: (129        <>130          <Search className="size-4 text-accent" aria-hidden />131          <span className="flex-1 text-sm text-accent">{/\s/.test(term) ? `Ask Company Atlas: “${term}”` : `Search everything for “${term}”`}</span>132          <CornerDownLeft className="size-3.5 text-ink-3" aria-hidden />133        </>134      ),135    });136  const clamp = Math.min(active, Math.max(0, rows.length - 1));137  const onKey = (e: React.KeyboardEvent) => {138    if (e.key === 'ArrowDown') {139      e.preventDefault();140      setActive((a) => Math.min(a + 1, rows.length - 1));141    } else if (e.key === 'ArrowUp') {142      e.preventDefault();143      setActive((a) => Math.max(a - 1, 0));144    } else if (e.key === 'Enter') {145      e.preventDefault();146      const it = rows[clamp];147      if (it) {148        it.run?.();149        go(it.href);150      } else if (term) go(routes.search(term));151    } else if (e.key === 'Escape') close();152  };153  let lastSection = '';154  return (155    <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[10vh]" role="dialog" aria-modal="true" aria-label="Search" onClick={close} data-palette>156      <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[72vh] md:w-[720px] md:rounded-lg" onClick={(e) => e.stopPropagation()}>157        <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5">158          <Search className="size-5 shrink-0 text-ink-3" aria-hidden />159          <input160            ref={inputRef}161            value={q}162            onChange={(e) => {163              setQ(e.target.value);164              setActive(0);165            }}166            onKeyDown={onKey}167            placeholder="Search companies, industries, countries… or ask a question"168            className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"169            autoComplete="off"170            spellCheck={false}171            aria-label="Search"172            role="combobox"173            aria-expanded={rows.length > 0}174            aria-controls="palette-list"175            aria-activedescendant={rows[clamp] ? `pal-${rows[clamp].key}` : undefined}176            data-palette-input177          />178          <button type="button" onClick={close} className="-mr-1 flex size-11 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close">179            <X className="size-5" aria-hidden />180          </button>181        </div>182        <div className="scrollbar-thin flex-1 overflow-y-auto">183          <ul id="palette-list" ref={listRef} className="py-1" role="listbox">184            {failed && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>}185            {term && !loading && !failed && items.length === 0 && <li className="px-4 py-2 text-xs text-ink-3">No direct match — search everything below.</li>}186            {rows.map((r, i) => {187              const header = r.section !== lastSection;188              lastSection = r.section;189              return (190                <li key={r.key} id={`pal-${r.key}`} role="option" aria-selected={i === clamp} data-palette-row>191                  {header && <p className="eyebrow px-4 pb-1 pt-3">{r.section}</p>}192                  <Link193                    href={r.href}194                    onClick={() => {195                      r.run?.();196                      close();197                    }}198                    onMouseEnter={() => setActive(i)}199                    className={cn('flex min-h-[44px] w-full items-center gap-3 px-4 py-2 text-left', i === clamp ? 'bg-surface-3' : 'hover:bg-surface-2')}200                  >201                    {r.node}202                    {i === clamp && <kbd className="mono hidden text-[10px] text-ink-3 sm:block">↵</kbd>}203                  </Link>204                </li>205              );206            })}207          </ul>208          {!term && (209            <div className="border-t border-rule px-4 py-4">210              <p className="eyebrow mb-2">Try asking</p>211              <ul className="flex flex-wrap gap-2">212                {EXAMPLE_QUERIES.map((ex) => (213                  <li key={ex}>214                    <button type="button" onClick={() => setQ(ex)} className="min-h-11 border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:min-h-9">215                      {ex}216                    </button>217                  </li>218                ))}219              </ul>220              <p className="eyebrow mt-5 mb-2">Browse</p>221              <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4">222                {primaryNav223                  .filter((n) => n.href !== '/')224                  .map((n) => (225                    <li key={n.href}>226                      <Link href={n.href} onClick={close} className="flex h-11 items-center gap-2 text-sm text-ink-2 hover:text-accent md:h-10">227                        <ArrowRight className="size-3.5" aria-hidden /> {n.label}228                      </Link>229                    </li>230                  ))}231              </ul>232            </div>233          )}234        </div>235        <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-ink-3">236          <span>237            <kbd className="mono border border-rule px-1">↵</kbd> open238          </span>239          <span>240            <kbd className="mono border border-rule px-1">↑↓</kbd> move241          </span>242          <span className="hidden sm:inline">Natural-language questions are routed to /ask and always link back to events and sources.</span>243          <span className="ml-auto">244            <kbd className="mono border border-rule px-1">esc</kbd> close245          </span>246        </div>247      </div>248    </div>249  );250}251