spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
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