SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
7.0 KB · 169 lines tsx
Raw Blame History
1'use client';23import { useCallback, useEffect, useRef, useState } from 'react';4import { useRouter } from 'next/navigation';5import { Search, CornerDownLeft } from 'lucide-react';67export interface SearchHit {8  type: 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication' | 'source';9  id: string;10  title: string;11  subtitle?: string | null;12  href: string;13  match: 'exact' | 'alias' | 'prefix' | 'trigram' | 'identifier';14}1516const TYPE_LABEL: Record<SearchHit['type'], string> = { cancer: 'Cancer', gene: 'Gene', variant: 'Variant', drug: 'Drug', trial: 'Trial', publication: 'Publication', source: 'Source' };1718/**19 * ⌘K command palette. Queries /api/search (own route handler, database-backed) with debounce.20 * Keyboard: ↑/↓ to move, Enter to open, Esc to close. Announces result count for screen readers.21 */22export function CommandPalette({ variant = 'button' }: { variant?: 'button' | 'hero' }) {23  const [open, setOpen] = useState(false);24  const [q, setQ] = useState('');25  const [hits, setHits] = useState<SearchHit[]>([]);26  const [active, setActive] = useState(0);27  const [loading, setLoading] = useState(false);28  const inputRef = useRef<HTMLInputElement>(null);29  const router = useRouter();3031  useEffect(() => {32    const onKey = (e: KeyboardEvent) => {33      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {34        e.preventDefault();35        setOpen((o) => !o);36      } else if (e.key === 'Escape') setOpen(false);37    };38    window.addEventListener('keydown', onKey);39    return () => window.removeEventListener('keydown', onKey);40  }, []);4142  useEffect(() => {43    if (open) setTimeout(() => inputRef.current?.focus(), 10);44    else {45      setQ('');46      setHits([]);47      setActive(0);48    }49  }, [open]);5051  useEffect(() => {52    if (!open) return;53    const term = q.trim();54    if (term.length < 2) {55      setHits([]);56      return;57    }58    const ctrl = new AbortController();59    const t = setTimeout(async () => {60      setLoading(true);61      try {62        const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=12`, { signal: ctrl.signal });63        const j = (await r.json()) as { data: SearchHit[] };64        setHits(j.data ?? []);65        setActive(0);66      } catch {67        /* aborted */68      } finally {69        setLoading(false);70      }71    }, 120);72    return () => {73      clearTimeout(t);74      ctrl.abort();75    };76  }, [q, open]);7778  const go = useCallback(79    (href: string) => {80      setOpen(false);81      router.push(href);82    },83    [router],84  );8586  const onKeyDown = (e: React.KeyboardEvent) => {87    if (e.key === 'ArrowDown') {88      e.preventDefault();89      setActive((a) => Math.min(hits.length - 1, a + 1));90    } else if (e.key === 'ArrowUp') {91      e.preventDefault();92      setActive((a) => Math.max(0, a - 1));93    } else if (e.key === 'Enter') {94      e.preventDefault();95      const h = hits[active];96      if (h) go(h.href);97      else if (q.trim()) go(`/search?q=${encodeURIComponent(q.trim())}`);98    }99  };100101  return (102    <>103      {variant === 'hero' ? (104        <button type="button" onClick={() => setOpen(true)} className="flex w-full items-center gap-3 border border-rule-strong bg-white px-4 py-3 text-left text-[15px] text-ink-3 hover:border-accent focus:border-accent">105          <Search className="h-4 w-4 shrink-0" aria-hidden />106          <span className="flex-1">Search a cancer, gene, variant, drug, trial or PMID…</span>107          <kbd className="ci-mono hidden rounded-sm border border-rule px-1.5 text-[11px] text-ink-3 sm:inline">⌘K</kbd>108        </button>109      ) : (110        <button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-2 border border-rule px-2.5 py-1.5 text-[13px] text-ink-2 hover:border-accent hover:text-accent" aria-label="Search (Command K)">111          <Search className="h-3.5 w-3.5" aria-hidden />112          <span className="hidden sm:inline">Search</span>113          <kbd className="ci-mono hidden rounded-sm border border-rule px-1 text-[10.5px] text-ink-3 md:inline">⌘K</kbd>114        </button>115      )}116      {open ? (117        <div className="fixed inset-0 z-[100] bg-ink/30 p-3 sm:p-[10vh]" onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)} role="presentation">118          <div role="dialog" aria-modal="true" aria-label="Search CancerIndex" className="mx-auto w-full max-w-2xl border border-rule-strong bg-paper shadow-2xl">119            <div className="flex items-center gap-2 border-b border-rule px-3">120              <Search className="h-4 w-4 text-ink-3" aria-hidden />121              <input122                ref={inputRef}123                value={q}124                onChange={(e) => setQ(e.target.value)}125                onKeyDown={onKeyDown}126                placeholder="Search cancers, genes, variants, drugs, trials, PMIDs…"127                className="w-full bg-transparent py-3 text-[15px] outline-none placeholder:text-ink-4"128                role="combobox"129                aria-expanded={hits.length > 0}130                aria-controls="ci-search-results"131                aria-activedescendant={hits[active] ? `ci-hit-${active}` : undefined}132                autoComplete="off"133                spellCheck={false}134              />135              <kbd className="ci-mono rounded-sm border border-rule px-1 text-[10.5px] text-ink-3">esc</kbd>136            </div>137            <ul id="ci-search-results" role="listbox" className="max-h-[60vh] overflow-y-auto">138              {hits.map((h, i) => (139                <li140                  key={`${h.type}-${h.id}`}141                  id={`ci-hit-${i}`}142                  role="option"143                  aria-selected={i === active}144                  onMouseEnter={() => setActive(i)}145                  onClick={() => go(h.href)}146                  className={`flex cursor-pointer items-center gap-3 border-b border-rule px-3 py-2 text-[14px] ${i === active ? 'bg-accent-soft' : ''}`}147                >148                  <span className="ci-kicker w-20 shrink-0">{TYPE_LABEL[h.type]}</span>149                  <span className="min-w-0 flex-1">150                    <span className="block truncate">{h.title}</span>151                    {h.subtitle ? <span className="block truncate text-[12px] text-ink-3">{h.subtitle}</span> : null}152                  </span>153                  <span className="ci-mono hidden text-[10.5px] text-ink-4 sm:inline">{h.match}</span>154                  {i === active ? <CornerDownLeft className="h-3.5 w-3.5 text-ink-3" aria-hidden /> : null}155                </li>156              ))}157              {q.trim().length >= 2 && !loading && hits.length === 0 ? <li className="px-3 py-4 text-[13.5px] text-ink-3">No matching entity. Press Enter for full-text search.</li> : null}158              {q.trim().length < 2 ? <li className="px-3 py-4 text-[13px] text-ink-3">Type at least two characters. Ordering: exact name, alias, prefix, then fuzzy match.</li> : null}159            </ul>160            <p className="sr-only" aria-live="polite">161              {hits.length} results162            </p>163          </div>164        </div>165      ) : null}166    </>167  );168}169