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%
4.4 KB · 109 lines tsx
Raw Blame History
1'use client';2import { Search } from 'lucide-react';3import { useEffect, useId, useRef, useState } from 'react';4import { clientApi } from '@/lib/client-api';5import { cn } from '@/lib/cn';6import type { Suggestion } from '@/lib/types';78/**9 * Model autocomplete on `/search/suggest` (type model), used by the calculator. Emits the chosen slug through `onSelect`;10 * works as a plain text field when JS is slow (the slug can be typed).11 */12const DEFAULT_TYPES = ['model'];13export function ModelPicker({ value, label, onSelect, placeholder = 'Type a model name…', className, inputClassName, types = DEFAULT_TYPES }: { value: string; label?: string | null; onSelect: (slug: string, name: string) => void; placeholder?: string; className?: string; inputClassName?: string; types?: string[] }) {14  const [q, setQ] = useState(label ?? value);15  const [items, setItems] = useState<Suggestion[]>([]);16  const [open, setOpen] = useState(false);17  const [active, setActive] = useState(0);18  const id = useId();19  const box = useRef<HTMLDivElement>(null);20  useEffect(() => {21    setQ(label ?? value);22  }, [label, value]);23  useEffect(() => {24    if (!open || q.trim().length < 2 || q === label) {25      setItems([]);26      return;27    }28    const ctrl = new AbortController();29    const t = setTimeout(() => {30      clientApi31        .suggest(q.trim(), ctrl.signal)32        .then((r) => setItems(r.items.filter((i) => types.includes(i.entity_type)).slice(0, 8)))33        .catch(() => setItems([]));34    }, 120);35    return () => {36      clearTimeout(t);37      ctrl.abort();38    };39  }, [q, open, label, types]);40  useEffect(() => {41    const onDoc = (e: MouseEvent) => {42      if (!box.current?.contains(e.target as Node)) setOpen(false);43    };44    document.addEventListener('mousedown', onDoc);45    return () => document.removeEventListener('mousedown', onDoc);46  }, []);47  const pick = (s: Suggestion) => {48    onSelect(s.slug, s.name);49    setQ(s.name);50    setOpen(false);51  };52  return (53    <div ref={box} className={cn('relative', className)}>54      <div className="relative">55        <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-3" aria-hidden />56        <input57          value={q}58          onChange={(e) => {59            setQ(e.target.value);60            setOpen(true);61            setActive(0);62          }}63          onFocus={() => setOpen(true)}64          onKeyDown={(e) => {65            if (!open || !items.length) {66              if (e.key === 'Enter' && q.trim() && !items.length) {67                onSelect(q.trim().toLowerCase().replace(/\s+/g, '-'), q.trim());68                setOpen(false);69              }70              return;71            }72            if (e.key === 'ArrowDown') {73              e.preventDefault();74              setActive((a) => (a + 1) % items.length);75            } else if (e.key === 'ArrowUp') {76              e.preventDefault();77              setActive((a) => (a - 1 + items.length) % items.length);78            } else if (e.key === 'Enter') {79              e.preventDefault();80              const s = items[active];81              if (s) pick(s);82            } else if (e.key === 'Escape') setOpen(false);83          }}84          placeholder={placeholder}85          role="combobox"86          aria-expanded={open && items.length > 0}87          aria-controls={`${id}-list`}88          aria-autocomplete="list"89          autoComplete="off"90          className={cn('h-11 w-full border border-rule bg-surface pl-9 pr-2.5 text-[15px] text-ink focus:border-accent focus:outline-none', inputClassName)}91          data-model-picker92        />93      </div>94      {open && items.length > 0 && (95        <ul id={`${id}-list`} role="listbox" className="panel absolute z-20 mt-1 max-h-72 w-full overflow-y-auto py-1 shadow-lg">96          {items.map((s, i) => (97            <li key={s.id} role="option" aria-selected={i === active}>98              <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => pick(s)} onMouseEnter={() => setActive(i)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 px-3 text-left text-sm', i === active ? 'bg-surface-2 text-ink' : 'text-ink-2 hover:bg-surface-2')} data-model-option>99                <span className="truncate">{s.name}</span>100                <span className="truncate text-xs text-ink-3">{s.organization_name ?? s.slug}</span>101              </button>102            </li>103          ))}104        </ul>105      )}106    </div>107  );108}109