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' ;
99import { useNavigate } from 'react-router-dom' ;
1010import { Search , FileText , Command } from 'lucide-react' ;
1111import { Button } from '@/components/ui/button' ;
@@ -21,59 +21,134 @@ import { cn } from '@/lib/utils';
2121import { searchDocs , fetchSearchIndex } from '@/lib/docs' ;
2222import 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 "{ query } "</ 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