Skip to content

Commit 63f16d7

Browse files
committed
feat: enhance search functionality with controlled state and improved UI
1 parent 5cc2af5 commit 63f16d7

3 files changed

Lines changed: 228 additions & 85 deletions

File tree

package-lock.json

Lines changed: 10 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/docs/SearchDialog.tsx

Lines changed: 186 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* with keyboard shortcut (Cmd/Ctrl + K)
66
*/
77

8-
import { useState, useEffect, useCallback } from 'react';
8+
import { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
99
import { useNavigate } from 'react-router-dom';
1010
import { Search, FileText, Command } from 'lucide-react';
1111
import { Button } from '@/components/ui/button';
@@ -21,59 +21,134 @@ import { cn } from '@/lib/utils';
2121
import { searchDocs, fetchSearchIndex } from '@/lib/docs';
2222
import type { SearchResult } from '@/types/docs';
2323

24-
export function SearchDialog({ projectId }: { projectId?: string }) {
25-
const [open, setOpen] = useState(false);
24+
// Module-level ref synchronizes state across all mounted instances.
25+
// When two instances are mounted (responsive triggers), clicking one opens only
26+
// that instance, leaving the other closed. A global Ctrl+K toggle would then
27+
// open the closed one while closing the open one. This shared ref prevents
28+
// that by letting every instance know "some dialog is already open."
29+
const sharedOpenRef = { current: false };
30+
31+
export function SearchDialog({
32+
projectId,
33+
open: controlledOpen,
34+
onOpenChange,
35+
}: {
36+
projectId?: string;
37+
open?: boolean;
38+
onOpenChange?: (open: boolean) => void;
39+
}) {
40+
const [internalOpen, setInternalOpen] = useState(false);
41+
const isControlled = controlledOpen !== undefined;
42+
const open = isControlled ? controlledOpen : internalOpen;
43+
const setOpen = useCallback(
44+
(value: boolean) => {
45+
if (isControlled) {
46+
onOpenChange?.(value);
47+
} else {
48+
setInternalOpen(value);
49+
}
50+
},
51+
[isControlled, onOpenChange],
52+
);
53+
2654
const [query, setQuery] = useState('');
2755
const [results, setResults] = useState<SearchResult[]>([]);
2856
const [selectedIndex, setSelectedIndex] = useState(0);
57+
const [isSearching, setIsSearching] = useState(false);
58+
const [searchError, setSearchError] = useState<string | null>(null);
59+
const abortRef = useRef<AbortController | null>(null);
2960
const navigate = useNavigate();
3061

31-
// Keyboard shortcut: Cmd/Ctrl + K
62+
// Sync both refs so the global listener can check cross-instance state
63+
sharedOpenRef.current = open;
64+
65+
// Global shortcut: Cmd/Ctrl + K toggles using the shared ref.
66+
// When ANY instance's dialog is open we close all; when none is open we open.
3267
useEffect(() => {
3368
const handleKeyDown = (e: KeyboardEvent) => {
3469
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
3570
e.preventDefault();
36-
setOpen(true);
37-
}
38-
if (e.key === 'Escape') {
39-
setOpen(false);
71+
if (sharedOpenRef.current) {
72+
setOpen(false);
73+
} else {
74+
setOpen(true);
75+
}
4076
}
4177
};
4278

4379
document.addEventListener('keydown', handleKeyDown);
4480
return () => document.removeEventListener('keydown', handleKeyDown);
45-
}, []);
81+
}, [setOpen]);
4682

47-
// Preload search index when dialog opens
83+
// Preload search index + reset when dialog opens/closes
4884
useEffect(() => {
4985
if (open) {
50-
fetchSearchIndex().catch(console.error);
86+
fetchSearchIndex().catch(() => {});
87+
setQuery('');
88+
setResults([]);
89+
setSelectedIndex(0);
90+
setIsSearching(false);
91+
setSearchError(null);
5192
}
5293
}, [open]);
5394

54-
// Search when query changes
95+
// Lock scroll directly on <html> rather than relying on react-remove-scroll-bar's
96+
// body overflow: hidden which causes a ~1px layout shift. Since html already has
97+
// scrollbar-gutter: stable, the space stays reserved even with overflow: hidden.
98+
useLayoutEffect(() => {
99+
if (!open) return;
100+
const html = document.documentElement;
101+
const saved = html.style.overflow;
102+
html.style.overflow = 'hidden';
103+
document.body.style.setProperty('padding-right', '0px', 'important');
104+
return () => {
105+
html.style.overflow = saved;
106+
document.body.style.removeProperty('padding-right');
107+
};
108+
}, [open]);
109+
110+
// Search with 150ms debounce + AbortController for request dedup
55111
useEffect(() => {
56-
let isActive = true;
57-
if (query.trim()) {
58-
searchDocs(query, projectId).then((searchResults) => {
59-
if (isActive) {
112+
if (!query.trim()) {
113+
setResults([]);
114+
setSearchError(null);
115+
return;
116+
}
117+
118+
const timer = setTimeout(async () => {
119+
setIsSearching(true);
120+
setSearchError(null);
121+
122+
abortRef.current?.abort();
123+
const controller = new AbortController();
124+
abortRef.current = controller;
125+
126+
try {
127+
const searchResults = await searchDocs(query, projectId);
128+
if (!controller.signal.aborted) {
60129
setResults(searchResults);
61130
setSelectedIndex(0);
62131
}
63-
}).catch(console.error);
64-
} else {
65-
setResults([]);
66-
}
132+
} catch {
133+
if (!controller.signal.aborted) {
134+
setSearchError('Search failed. Check your connection and try again.');
135+
}
136+
} finally {
137+
if (!controller.signal.aborted) {
138+
setIsSearching(false);
139+
}
140+
}
141+
}, 150);
142+
67143
return () => {
68-
isActive = false;
144+
clearTimeout(timer);
69145
};
70146
}, [query, projectId]);
71147

72148
const handleSelect = useCallback((result: SearchResult) => {
73149
navigate(result.href);
74150
setOpen(false);
75-
setQuery('');
76-
}, [navigate]);
151+
}, [navigate, setOpen]);
77152

78153
// Keyboard navigation
79154
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -93,35 +168,47 @@ export function SearchDialog({ projectId }: { projectId?: string }) {
93168

94169
return (
95170
<>
96-
{/* Search Button */}
97-
<Button
98-
variant="outline"
99-
size="sm"
100-
className="hidden md:flex items-center gap-2 text-muted-foreground hover:text-foreground"
101-
onClick={() => setOpen(true)}
102-
>
103-
<Search className="h-4 w-4" />
104-
<span className="text-sm">Search</span>
105-
<kbd className="ml-2 hidden lg:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
106-
<Command className="h-3 w-3" />
107-
<span>K</span>
108-
</kbd>
109-
</Button>
110-
111-
{/* Mobile Search Button */}
112-
<Button
113-
variant="ghost"
114-
size="icon"
115-
className="md:hidden"
116-
onClick={() => setOpen(true)}
117-
aria-label="Search"
118-
>
119-
<Search className="h-5 w-5" />
120-
</Button>
171+
{!isControlled && (
172+
<>
173+
{/* Search Button */}
174+
<Button
175+
variant="outline"
176+
size="sm"
177+
className="hidden md:flex items-center gap-2 text-muted-foreground hover:text-foreground"
178+
onClick={() => setOpen(true)}
179+
>
180+
<Search className="h-4 w-4" />
181+
<span className="text-sm">Search</span>
182+
<kbd className="ml-2 hidden lg:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
183+
<Command className="h-3 w-3" />
184+
<span>K</span>
185+
</kbd>
186+
</Button>
187+
188+
{/* Mobile Search Button */}
189+
<Button
190+
variant="ghost"
191+
size="icon"
192+
className="md:hidden"
193+
onClick={() => setOpen(true)}
194+
aria-label="Search"
195+
>
196+
<Search className="h-5 w-5" />
197+
</Button>
198+
</>
199+
)}
121200

122201
{/* Search Dialog */}
123202
<Dialog open={open} onOpenChange={setOpen}>
124-
<DialogContent className="max-w-2xl p-0 gap-0">
203+
<DialogContent
204+
className="max-w-2xl p-0 gap-0 shadow-none [box-shadow:0_0_24px_hsl(var(--primary)/0.08)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-90 data-[state=open]:zoom-in-90"
205+
style={{
206+
'--tw-enter-translate-x': '-50%',
207+
'--tw-enter-translate-y': '-50%',
208+
'--tw-exit-translate-x': '-50%',
209+
'--tw-exit-translate-y': '-50%',
210+
} as React.CSSProperties}
211+
>
125212
<DialogHeader className="p-4 pb-0">
126213
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
127214
<div className="relative">
@@ -131,26 +218,47 @@ export function SearchDialog({ projectId }: { projectId?: string }) {
131218
/>
132219
<Input
133220
placeholder="Search documentation..."
134-
className="pl-10 h-12 text-lg"
221+
className={cn(
222+
'pl-10 h-12 text-lg',
223+
isSearching && 'pr-10'
224+
)}
135225
value={query}
136226
onChange={(e) => setQuery(e.target.value)}
137227
onKeyDown={handleKeyDown}
138228
autoFocus
139229
role="combobox"
140230
aria-label="Search documentation"
141231
aria-expanded={results.length > 0}
232+
aria-busy={isSearching}
142233
aria-controls="search-results-listbox"
143234
aria-activedescendant={
144235
results[selectedIndex]
145236
? `search-result-${selectedIndex}`
146237
: undefined
147238
}
148239
/>
240+
{isSearching && (
241+
<div
242+
className="absolute right-3 top-1/2 -translate-y-1/2"
243+
role="status"
244+
aria-live="polite"
245+
>
246+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
247+
<span className="sr-only">Searching...</span>
248+
</div>
249+
)}
149250
</div>
150251
</DialogHeader>
151252

152253
<div className="p-4 pt-2">
153-
{query.trim() && results.length === 0 ? (
254+
{searchError ? (
255+
<div
256+
className="text-center py-8 text-muted-foreground"
257+
role="alert"
258+
>
259+
<p>{searchError}</p>
260+
</div>
261+
) : query.trim() && results.length === 0 && !isSearching ? (
154262
<div
155263
className="text-center py-8 text-muted-foreground"
156264
role="status"
@@ -159,7 +267,7 @@ export function SearchDialog({ projectId }: { projectId?: string }) {
159267
<p>No results found for &quot;{query}&quot;</p>
160268
<p className="text-sm mt-1">Try a different search term</p>
161269
</div>
162-
) : (
270+
) : results.length > 0 ? (
163271
<ScrollArea className="max-h-[60vh]">
164272
<ul
165273
id="search-results-listbox"
@@ -181,7 +289,9 @@ export function SearchDialog({ projectId }: { projectId?: string }) {
181289
onClick={() => handleSelect(result)}
182290
className={cn(
183291
'w-full text-left p-3 rounded-lg transition-colors',
184-
isSelected ? 'bg-primary/10' : 'hover:bg-muted'
292+
isSelected
293+
? 'bg-primary/10 ring-1 ring-primary/20'
294+
: 'hover:bg-muted'
185295
)}
186296
>
187297
<div className="flex items-start gap-3">
@@ -207,29 +317,31 @@ export function SearchDialog({ projectId }: { projectId?: string }) {
207317
})}
208318
</ul>
209319
</ScrollArea>
210-
)}
320+
) : null}
211321

212322
{/* Keyboard shortcuts hint */}
213-
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t text-xs text-muted-foreground">
214-
<span className="flex items-center gap-1">
215-
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
216-
↑↓
217-
</kbd>
218-
to navigate
219-
</span>
220-
<span className="flex items-center gap-1">
221-
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
222-
223-
</kbd>
224-
to select
225-
</span>
226-
<span className="flex items-center gap-1">
227-
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
228-
esc
229-
</kbd>
230-
to close
231-
</span>
232-
</div>
323+
{results.length > 0 && (
324+
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t text-xs text-muted-foreground">
325+
<span className="flex items-center gap-1">
326+
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
327+
↑↓
328+
</kbd>
329+
to navigate
330+
</span>
331+
<span className="flex items-center gap-1">
332+
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
333+
334+
</kbd>
335+
to select
336+
</span>
337+
<span className="flex items-center gap-1">
338+
<kbd className="px-1.5 py-0.5 rounded border bg-muted font-mono">
339+
esc
340+
</kbd>
341+
to close
342+
</span>
343+
</div>
344+
)}
233345
</div>
234346
</DialogContent>
235347
</Dialog>

0 commit comments

Comments
 (0)