diff --git a/.gitignore b/.gitignore index 4e9e56a..3f6aab3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local # Editor directories and files +package-lock.json .vscode/* !.vscode/extensions.json .idea diff --git a/src/components/ExplorerSearch.tsx b/src/components/ExplorerSearch.tsx new file mode 100644 index 0000000..f1bac26 --- /dev/null +++ b/src/components/ExplorerSearch.tsx @@ -0,0 +1,132 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { courseList } from '@/data/loadCourses'; +import { useExplorerStore } from '@/store/explorerStore'; + +const MAX_RESULTS = 8; + +function normalize(s: string): string { + return s.toLowerCase().replace(/\s+/g, ''); +} + +interface Result { + code: string; + title: string; +} + +function search(query: string): Result[] { + if (!query.trim()) return []; + const q = normalize(query); + const codePrefixMatches: Result[] = []; + const otherMatches: Result[] = []; + + for (const course of courseList) { + const normCode = normalize(course.code); + const normTitle = normalize(course.title); + if (normCode.startsWith(q)) { + codePrefixMatches.push({ code: course.code, title: course.title }); + } else if (normCode.includes(q) || normTitle.includes(q)) { + otherMatches.push({ code: course.code, title: course.title }); + } + } + return [...codePrefixMatches, ...otherMatches].slice(0, MAX_RESULTS); +} + +export default function ExplorerSearch() { + const { setSelectedCourse } = useExplorerStore(); + const [query, setQuery] = useState(''); + const results = useMemo(() => search(query), [query]); + const [activeIndex, setActiveIndex] = useState(-1); + const inputRef = useRef(null); + const listRef = useRef(null); + + const selectResult = useCallback( + (code: string) => { + setSelectedCourse(code); + setQuery(''); + setActiveIndex(-1); + }, + [setSelectedCourse], + ); + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, -1)); + } else if (e.key === 'Enter') { + if (activeIndex >= 0 && results[activeIndex]) { + selectResult(results[activeIndex].code); + } else if (results.length === 1) { + selectResult(results[0].code); + } + } else if (e.key === 'Escape') { + setQuery(''); + inputRef.current?.blur(); + } + } + + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const item = listRef.current.children[activeIndex] as HTMLElement; + item?.scrollIntoView({ block: 'nearest' }); + } + }, [activeIndex]); + + const open = results.length > 0; + + return ( +
+ { + setQuery(e.target.value); + setActiveIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search courses…" + aria-label="Search courses" + aria-autocomplete="list" + aria-expanded={open} + aria-controls={open ? 'explorer-search-results' : undefined} + aria-activedescendant={ + activeIndex >= 0 ? `explorer-result-${activeIndex}` : undefined + } + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-md outline-none focus:border-red-500 focus:ring-2 focus:ring-red-200" + /> + {open && ( +
    + {results.map((r, i) => ( +
  • { + e.preventDefault(); + selectResult(r.code); + }} + onMouseEnter={() => setActiveIndex(i)} + className={`cursor-pointer px-3 py-2 text-sm ${ + i === activeIndex + ? 'bg-gray-50 text-red-700' + : 'text-gray-800 hover:bg-gray-50' + }`} + > + {r.code} + — {r.title} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/pages/Explorer.tsx b/src/pages/Explorer.tsx index a198501..8ceb825 100644 --- a/src/pages/Explorer.tsx +++ b/src/pages/Explorer.tsx @@ -24,6 +24,7 @@ import { useExplorerStore } from '@/store/explorerStore'; import CourseNode from '@/components/CourseNode'; import type { CourseNodeData } from '@/components/CourseNode'; import CourseDetailPanel from '@/components/CourseDetailPanel'; +import ExplorerSearch from '@/components/ExplorerSearch'; const NODE_W = 180; const NODE_H = 60; @@ -106,6 +107,7 @@ export default function Explorer() { return (
+