MuSProt / frontend /src /components /SearchBox.tsx
WinslowFan's picture
Claude Opus 5.5
Redesign the MuSProt website for the new database release
dd079af
Raw History Blame Contribute Delete
6.18 kB
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<Suggestion[]>([]);
const [loading, setLoading] = useState(false);
const debounced = useDebounced(value.trim(), 220);
const listId = useId();
const wrapRef = useRef<HTMLDivElement>(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 (
<div className={`search search--${size}`} ref={wrapRef}>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<Search className="search__icon" aria-hidden />
<input
type="search"
value={value}
autoFocus={autoFocus}
placeholder={placeholder}
aria-label="Search MuSProt"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
role="combobox"
autoComplete="off"
spellCheck={false}
onFocus={() => 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 && (
<button type="button" className="search__clear" aria-label="Clear" onClick={() => setValue('')}>
<X aria-hidden />
</button>
)}
<button type="submit" className="search__submit">
Search
</button>
</form>
{showList && (
<div className="search__pop" id={listId} role="listbox">
{loading && items.length === 0 && <div className="search__hint">Searching…</div>}
{!loading && items.length === 0 && (
<div className="search__hint">
Press <kbd>Enter</kbd> to search all fields for “{value.trim()}”
</div>
)}
{items.map((it, i) => (
<button
key={it.key}
type="button"
role="option"
aria-selected={i === active}
className={`search__item ${i === active ? 'is-active' : ''}`}
onMouseEnter={() => setActive(i)}
onClick={() => go(it.to)}
>
<span className={`search__kind search__kind--${it.icon}`}>
{it.icon === 'chain' ? <Box aria-hidden /> : it.icon === 'sequence' ? <Layers aria-hidden /> : <FileText aria-hidden />}
</span>
<span className="search__text">
<span className="search__primary mono">{it.primary}</span>
{it.secondary && <span className="search__secondary">{it.secondary}</span>}
</span>
{i === active && <CornerDownLeft className="search__enter" aria-hidden />}
</button>
))}
</div>
)}
</div>
);
}