HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { Search } from 'lucide-react';3import { useEffect, useId, useRef, useState } from 'react';4import { EntityBadge } from '@/components/ui/badges';5import { cn } from '@/lib/cn';6import { clientApi } from '@/lib/client-api';7import type { Suggestion } from '@/lib/types';89/** Root picker: `/search/suggest` autocomplete (≤ 8 rows), keyboard navigable, calls `onPick(suggestion)`. */10export function NodeSearch({ onPick, placeholder = 'Pick a root: model, organization, benchmark…', className, autoFocus = false }: { onPick: (s: Suggestion) => void; placeholder?: string; className?: string; autoFocus?: boolean }) {11 const [q, setQ] = useState('');12 const [items, setItems] = useState<Suggestion[]>([]);13 const [open, setOpen] = useState(false);14 const [active, setActive] = useState(0);15 const abort = useRef<AbortController | null>(null);16 const listId = useId();17 useEffect(() => {18 const term = q.trim();19 if (term.length < 2) {20 setItems([]);21 return;22 }23 abort.current?.abort();24 const ctl = new AbortController();25 abort.current = ctl;26 const t = setTimeout(() => {27 clientApi28 .suggest(term, ctl.signal)29 .then((r) => {30 setItems(r.items);31 setActive(0);32 setOpen(true);33 })34 .catch(() => undefined);35 }, 120);36 return () => clearTimeout(t);37 }, [q]);38 const pick = (s: Suggestion) => {39 onPick(s);40 setQ('');41 setItems([]);42 setOpen(false);43 };44 return (45 <div className={cn('relative', className)}>46 <div className="flex items-stretch border border-rule bg-surface focus-within:border-accent">47 <span className="flex items-center pl-2.5 text-ink-3">48 <Search className="size-4" aria-hidden />49 </span>50 <input51 type="search"52 value={q}53 onChange={(e) => setQ(e.target.value)}54 onFocus={() => items.length && setOpen(true)}55 onBlur={() => setTimeout(() => setOpen(false), 120)}56 onKeyDown={(e) => {57 if (e.key === 'ArrowDown') {58 e.preventDefault();59 setActive((a) => Math.min(items.length - 1, a + 1));60 } else if (e.key === 'ArrowUp') {61 e.preventDefault();62 setActive((a) => Math.max(0, a - 1));63 } else if (e.key === 'Enter' && items[active]) {64 e.preventDefault();65 pick(items[active]);66 } else if (e.key === 'Escape') setOpen(false);67 }}68 placeholder={placeholder}69 aria-label="Search a root entity"70 aria-autocomplete="list"71 aria-controls={listId}72 aria-expanded={open}73 autoComplete="off"74 autoFocus={autoFocus}75 className="h-11 min-w-0 flex-1 bg-transparent px-2 text-[15px] text-ink placeholder:text-ink-3 focus:outline-none"76 data-graph-search77 />78 </div>79 {open && items.length > 0 && (80 <ul id={listId} role="listbox" className="panel absolute left-0 right-0 top-full z-20 mt-1 max-h-72 overflow-auto py-1 text-sm shadow-lg">81 {items.map((s, i) => (82 <li key={s.id} role="option" aria-selected={i === active}>83 <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => pick(s)} onMouseEnter={() => setActive(i)} className={cn('flex w-full items-center gap-2 px-2.5 py-2 text-left', i === active ? 'bg-surface-2 text-ink' : 'text-ink-2 hover:bg-surface-2')}>84 <EntityBadge type={s.entity_type} small />85 <span className="truncate">{s.name}</span>86 {s.organization_name && <span className="ml-auto shrink-0 text-xs text-ink-3">{s.organization_name}</span>}87 </button>88 </li>89 ))}90 </ul>91 )}92 </div>93 );94}95