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 { 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