11"use client" ;
22
3- import { useState , useEffect } from "react" ;
3+ import type { ReactNode } from "react" ;
4+ import { useCallback , useEffect , useState } from "react" ;
45import { createPortal } from "react-dom" ;
56import { Command , Search } from "lucide-react" ;
67import { SearchModal } from "./search-modal" ;
78
9+ const TOGGLE_SEARCH_EVENT = "shell-docs:toggle-search" ;
10+
811function isEditableTarget ( target : EventTarget | null ) : boolean {
912 if ( ! target || ! ( target instanceof HTMLElement ) ) return false ;
1013 const tag = target . tagName ;
@@ -13,23 +16,14 @@ function isEditableTarget(target: EventTarget | null): boolean {
1316 return false ;
1417}
1518
16- export function SearchTrigger ( {
17- iconOnly = false ,
18- } : { iconOnly ?: boolean } = { } ) {
19- // Start as null so SSR output matches the initial client render; resolve
20- // after mount to avoid hydration mismatch flashing ⌘K → Ctrl+K on non-Mac.
21- const [ isMac , setIsMac ] = useState < boolean | null > ( null ) ;
19+ export function ShellSearchProvider ( { children } : { children : ReactNode } ) {
2220 const [ open , setOpen ] = useState ( false ) ;
23-
24- useEffect ( ( ) => {
25- const mac =
26- typeof navigator !== "undefined" && / m a c / i. test ( navigator . userAgent ) ;
27- setIsMac ( mac ) ;
28- } , [ ] ) ;
21+ const closeSearch = useCallback ( ( ) => setOpen ( false ) , [ ] ) ;
22+ const toggleSearch = useCallback ( ( ) => setOpen ( ( prev ) => ! prev ) , [ ] ) ;
2923
3024 useEffect ( ( ) => {
3125 function onKeyDown ( e : KeyboardEvent ) {
32- if ( ( e . metaKey || e . ctrlKey ) && e . key === "k" ) {
26+ if ( ( e . metaKey || e . ctrlKey ) && e . key . toLowerCase ( ) === "k" ) {
3327 // Don't hijack Cmd/Ctrl+K when the user is typing in an unrelated
3428 // input / textarea / contenteditable — only steal the shortcut when
3529 // focus is outside an editable element or already inside our own
@@ -40,26 +34,55 @@ export function SearchTrigger({
4034 if ( isEditableTarget ( target ) && ! insideSearchModal ) return ;
4135
4236 e . preventDefault ( ) ;
43- setOpen ( ( prev ) => ! prev ) ;
37+ e . stopPropagation ( ) ;
38+ toggleSearch ( ) ;
4439 }
45- if ( e . key === "Escape" ) setOpen ( false ) ;
40+ if ( e . key === "Escape" ) closeSearch ( ) ;
4641 }
47- document . addEventListener ( "keydown" , onKeyDown ) ;
48- return ( ) => document . removeEventListener ( "keydown" , onKeyDown ) ;
42+
43+ document . addEventListener ( "keydown" , onKeyDown , { capture : true } ) ;
44+ window . addEventListener ( TOGGLE_SEARCH_EVENT , toggleSearch ) ;
45+
46+ return ( ) => {
47+ document . removeEventListener ( "keydown" , onKeyDown , { capture : true } ) ;
48+ window . removeEventListener ( TOGGLE_SEARCH_EVENT , toggleSearch ) ;
49+ } ;
50+ } , [ closeSearch , toggleSearch ] ) ;
51+
52+ return (
53+ < >
54+ { children }
55+ { open && < SearchModalWrapper onClose = { closeSearch } /> }
56+ </ >
57+ ) ;
58+ }
59+
60+ function toggleShellSearch ( ) {
61+ window . dispatchEvent ( new Event ( TOGGLE_SEARCH_EVENT ) ) ;
62+ }
63+
64+ export function SearchTrigger ( {
65+ iconOnly = false ,
66+ } : { iconOnly ?: boolean } = { } ) {
67+ // Start as null so SSR output matches the initial client render; resolve
68+ // after mount to avoid hydration mismatch flashing ⌘K → Ctrl+K on non-Mac.
69+ const [ isMac , setIsMac ] = useState < boolean | null > ( null ) ;
70+
71+ useEffect ( ( ) => {
72+ const mac =
73+ typeof navigator !== "undefined" && / m a c / i. test ( navigator . userAgent ) ;
74+ setIsMac ( mac ) ;
4975 } , [ ] ) ;
5076
5177 if ( iconOnly ) {
5278 return (
53- < >
54- < button
55- onClick = { ( ) => setOpen ( ( prev ) => ! prev ) }
56- className = "flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
57- aria-label = "Search"
58- >
59- < Search className = "h-4 w-4" aria-hidden = "true" />
60- </ button >
61- { open && < SearchModalWrapper onClose = { ( ) => setOpen ( false ) } /> }
62- </ >
79+ < button
80+ onClick = { toggleShellSearch }
81+ className = "flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
82+ aria-label = "Search"
83+ >
84+ < Search className = "h-4 w-4" aria-hidden = "true" />
85+ </ button >
6386 ) ;
6487 }
6588
@@ -68,7 +91,7 @@ export function SearchTrigger({
6891 return (
6992 < >
7093 < button
71- onClick = { ( ) => setOpen ( ( prev ) => ! prev ) }
94+ onClick = { toggleShellSearch }
7295 aria-label = "Search"
7396 className = "lg:min-w-[250px] xl:min-w-[300px] flex gap-2 items-center px-3 h-10 rounded-xl cursor-pointer border border-[var(--border)] bg-[var(--bg-elevated)]/70 text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-surface)] transition-colors shadow-[0_1px_0_rgba(1,5,7,0.03)]"
7497 >
@@ -96,7 +119,6 @@ export function SearchTrigger({
96119 ) }
97120 </ span >
98121 </ button >
99- { open && < SearchModalWrapper onClose = { ( ) => setOpen ( false ) } /> }
100122 </ >
101123 ) ;
102124}
0 commit comments