import { useEffect, useId, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Search, X, CornerDownLeft, Layers, Box, FileText } from 'lucide-react'; import { api, type SearchResponse } from '../lib/api'; import { useDebounced } from '../lib/hooks'; import { chainLabel, chainPath, cleanFunction, sequencePath } from '../lib/format'; const CHAIN_RE = /^([0-9][A-Za-z0-9]{3})\s*[.:_\s/-]\s*([A-Za-z0-9]{1,4})$/; interface Suggestion { key: string; to: string; icon: 'chain' | 'sequence' | 'text'; primary: string; secondary?: string; } function toSuggestions(r: SearchResponse): Suggestion[] { const out: Suggestion[] = []; for (const c of r.chains.slice(0, 8)) { out.push({ key: `c-${c.pdb_id}-${c.auth_asym_id}`, to: chainPath(c.pdb_id, c.auth_asym_id), icon: 'chain', primary: chainLabel(c.pdb_id, c.auth_asym_id), secondary: [c.uniprot_id, c.sequence_id, c.state_id != null ? `state ${c.state_id}` : null].filter(Boolean).join(' · '), }); } for (const s of r.sequences.slice(0, 8)) { out.push({ key: `s-${s.sequence_id}`, to: sequencePath(s.sequence_id), icon: 'sequence', primary: `${s.sequence_id}${s.uniprot_id ? ` · ${s.uniprot_id}` : ''}`, secondary: `${s.n_obs.toLocaleString()} observations · ${s.n_states.toLocaleString()} states${ s.top_function ? ` — ${cleanFunction(s.top_function)}` : '' }`, }); } return out; } export function SearchBox({ size = 'md', autoFocus = false, placeholder = 'Search a chain (3vgk.A), PDB entry, UniProt, SEQ ID or function…', }: { size?: 'md' | 'lg'; autoFocus?: boolean; placeholder?: string; }) { const navigate = useNavigate(); const [value, setValue] = useState(''); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const debounced = useDebounced(value.trim(), 220); const listId = useId(); const wrapRef = useRef(null); useEffect(() => { if (debounced.length < 2) { setItems([]); return; } const controller = new AbortController(); setLoading(true); api .search(debounced, controller.signal) .then((r) => { setItems(toSuggestions(r)); setActive(-1); }) .catch(() => { if (!controller.signal.aborted) setItems([]); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [debounced]); useEffect(() => { const onDoc = (e: MouseEvent) => { if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, []); const go = (to: string) => { setOpen(false); setValue(''); navigate(to); }; const submit = () => { if (active >= 0 && items[active]) return go(items[active].to); const q = value.trim(); if (!q) return; const m = CHAIN_RE.exec(q); if (m) return go(chainPath(m[1], m[2])); go(`/search?q=${encodeURIComponent(q)}`); }; const showList = open && value.trim().length >= 2; return (
{ e.preventDefault(); submit(); }} > setOpen(true)} onChange={(e) => { setValue(e.target.value); setOpen(true); }} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(items.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(-1, a - 1)); } else if (e.key === 'Escape') { setOpen(false); } }} /> {value && ( )} {showList && (
{loading && items.length === 0 &&
Searching…
} {!loading && items.length === 0 && (
Press Enter to search all fields for “{value.trim()}”
)} {items.map((it, i) => ( ))}
)}
); }