SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
3.8 KB · 95 lines tsx
Raw Blame History
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