Download frontend/src/components/SearchBox.tsx from omaib/MuSProt: direct link, hf CLI and curl.
- Browser
- Download file 6.18 kB
-
https://huggingface.co/spaces/omaib/MuSProt/resolve/main/frontend/src/components/SearchBox.tsx
- Command line
-
hf download hf://spaces/omaib/MuSProt/frontend/src/components/SearchBox.tsx
-
curl -L -o SearchBox.tsx https://huggingface.co/spaces/omaib/MuSProt/resolve/main/frontend/src/components/SearchBox.tsx
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> | |
| ); | |
| } | |