Skip to content

Commit 309671e

Browse files
authored
feat: add type-ahead search to Explorer (#23)
* feat: added ExplorerSearch component with type-ahead filtering * feat: implement explorer search with type-ahead filtering and keyboard navigation * format explorer search ---------
1 parent 7d52db3 commit 309671e

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dist-ssr
1313
*.local
1414

1515
# Editor directories and files
16+
package-lock.json
1617
.vscode/*
1718
!.vscode/extensions.json
1819
.idea

src/components/ExplorerSearch.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
2+
import { courseList } from '@/data/loadCourses';
3+
import { useExplorerStore } from '@/store/explorerStore';
4+
5+
const MAX_RESULTS = 8;
6+
7+
function normalize(s: string): string {
8+
return s.toLowerCase().replace(/\s+/g, '');
9+
}
10+
11+
interface Result {
12+
code: string;
13+
title: string;
14+
}
15+
16+
function search(query: string): Result[] {
17+
if (!query.trim()) return [];
18+
const q = normalize(query);
19+
const codePrefixMatches: Result[] = [];
20+
const otherMatches: Result[] = [];
21+
22+
for (const course of courseList) {
23+
const normCode = normalize(course.code);
24+
const normTitle = normalize(course.title);
25+
if (normCode.startsWith(q)) {
26+
codePrefixMatches.push({ code: course.code, title: course.title });
27+
} else if (normCode.includes(q) || normTitle.includes(q)) {
28+
otherMatches.push({ code: course.code, title: course.title });
29+
}
30+
}
31+
return [...codePrefixMatches, ...otherMatches].slice(0, MAX_RESULTS);
32+
}
33+
34+
export default function ExplorerSearch() {
35+
const { setSelectedCourse } = useExplorerStore();
36+
const [query, setQuery] = useState('');
37+
const results = useMemo(() => search(query), [query]);
38+
const [activeIndex, setActiveIndex] = useState(-1);
39+
const inputRef = useRef<HTMLInputElement>(null);
40+
const listRef = useRef<HTMLUListElement>(null);
41+
42+
const selectResult = useCallback(
43+
(code: string) => {
44+
setSelectedCourse(code);
45+
setQuery('');
46+
setActiveIndex(-1);
47+
},
48+
[setSelectedCourse],
49+
);
50+
51+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
52+
if (e.key === 'ArrowDown') {
53+
e.preventDefault();
54+
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
55+
} else if (e.key === 'ArrowUp') {
56+
e.preventDefault();
57+
setActiveIndex((i) => Math.max(i - 1, -1));
58+
} else if (e.key === 'Enter') {
59+
if (activeIndex >= 0 && results[activeIndex]) {
60+
selectResult(results[activeIndex].code);
61+
} else if (results.length === 1) {
62+
selectResult(results[0].code);
63+
}
64+
} else if (e.key === 'Escape') {
65+
setQuery('');
66+
inputRef.current?.blur();
67+
}
68+
}
69+
70+
useEffect(() => {
71+
if (activeIndex >= 0 && listRef.current) {
72+
const item = listRef.current.children[activeIndex] as HTMLElement;
73+
item?.scrollIntoView({ block: 'nearest' });
74+
}
75+
}, [activeIndex]);
76+
77+
const open = results.length > 0;
78+
79+
return (
80+
<div className="absolute left-4 top-4 z-10 w-72">
81+
<input
82+
ref={inputRef}
83+
type="search"
84+
value={query}
85+
onChange={(e) => {
86+
setQuery(e.target.value);
87+
setActiveIndex(-1);
88+
}}
89+
onKeyDown={handleKeyDown}
90+
placeholder="Search courses…"
91+
aria-label="Search courses"
92+
aria-autocomplete="list"
93+
aria-expanded={open}
94+
aria-controls={open ? 'explorer-search-results' : undefined}
95+
aria-activedescendant={
96+
activeIndex >= 0 ? `explorer-result-${activeIndex}` : undefined
97+
}
98+
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"
99+
/>
100+
{open && (
101+
<ul
102+
ref={listRef}
103+
id="explorer-search-results"
104+
role="listbox"
105+
className="mt-1 max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg"
106+
>
107+
{results.map((r, i) => (
108+
<li
109+
key={r.code}
110+
id={`explorer-result-${i}`}
111+
role="option"
112+
aria-selected={i === activeIndex}
113+
onMouseDown={(e) => {
114+
e.preventDefault();
115+
selectResult(r.code);
116+
}}
117+
onMouseEnter={() => setActiveIndex(i)}
118+
className={`cursor-pointer px-3 py-2 text-sm ${
119+
i === activeIndex
120+
? 'bg-gray-50 text-red-700'
121+
: 'text-gray-800 hover:bg-gray-50'
122+
}`}
123+
>
124+
<span className="font-medium">{r.code}</span>
125+
<span className="ml-2 text-gray-500">{r.title}</span>
126+
</li>
127+
))}
128+
</ul>
129+
)}
130+
</div>
131+
);
132+
}

src/pages/Explorer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useExplorerStore } from '@/store/explorerStore';
2424
import CourseNode from '@/components/CourseNode';
2525
import type { CourseNodeData } from '@/components/CourseNode';
2626
import CourseDetailPanel from '@/components/CourseDetailPanel';
27+
import ExplorerSearch from '@/components/ExplorerSearch';
2728

2829
const NODE_W = 180;
2930
const NODE_H = 60;
@@ -106,6 +107,7 @@ export default function Explorer() {
106107

107108
return (
108109
<div className="relative h-full w-full">
110+
<ExplorerSearch />
109111
<ReactFlow
110112
nodes={nodes}
111113
edges={edges}

0 commit comments

Comments
 (0)