@@ -7,8 +7,8 @@ import Stack from "@mui/material/Stack";
77import ExpandMoreIcon from "@mui/icons-material/ExpandMore" ;
88import ExpandLessIcon from "@mui/icons-material/ExpandLess" ;
99import { Link } from "react-router-dom" ;
10- import { Collapse , TextField , InputAdornment , IconButton } from "@mui/material" ;
11- import { useState } from "react" ;
10+ import { Collapse , TextField , InputAdornment , IconButton , Box } from "@mui/material" ;
11+ import { useEffect , useMemo , useRef , useState } from "react" ;
1212import { useAppStore } from "@/stores/app-state" ;
1313import SearchIcon from "@mui/icons-material/Search" ;
1414import CloseIcon from "@mui/icons-material/Close" ;
@@ -25,21 +25,17 @@ const NavigationLink = ({ to, icon, text, onClick }) => {
2525 ) ;
2626} ;
2727
28- const Group = ( { groupName, icon, level = 1 , children } ) => {
29- const [ open , setOpen ] = useState ( true ) ;
30-
31- const handleClick = ( ) => {
32- setOpen ( ! open ) ;
33- } ;
28+ const Group = ( { groupName, icon, level = 1 , open, forceOpen = false , onToggle, children } ) => {
29+ const isOpen = forceOpen || open ;
3430
3531 return (
3632 < ListItem key = { groupName } disablePadding sx = { { display : "block" } } >
37- < ListItemButton onClick = { handleClick } >
33+ < ListItemButton onClick = { onToggle } >
3834 { icon && < ListItemIcon > { icon } </ ListItemIcon > }
3935 < ListItemText primary = { groupName } />
40- { open ? < ExpandLessIcon /> : < ExpandMoreIcon /> }
36+ { isOpen ? < ExpandLessIcon /> : < ExpandMoreIcon /> }
4137 </ ListItemButton >
42- < Collapse in = { open } timeout = { "auto" } unmountOnExit >
38+ < Collapse in = { isOpen } timeout = { "auto" } unmountOnExit >
4339 < List
4440 dense
4541 sx = { { "& .MuiListItemButton-root" : { pl : 2 * level } } }
@@ -54,7 +50,46 @@ const Group = ({ groupName, icon, level = 1, children }) => {
5450
5551export default function MenuContent ( { navigationTree, isMobile } ) {
5652 const toggleDrawer = useAppStore ( ( state ) => state . toggleDrawer ) ;
53+ const groupOpenState = useAppStore ( ( state ) => state . groupOpenState ) ;
54+ const toggleMenuGroupOpen = useAppStore ( ( state ) => state . toggleMenuGroupOpen ) ;
5755 const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
56+ const searchInputRef = useRef ( null ) ;
57+
58+ const isApplePlatform = useMemo ( ( ) => {
59+ if ( typeof navigator === "undefined" ) return false ;
60+ const ua = navigator . userAgent || "" ;
61+ const uaPlatform = navigator . userAgentData ?. platform || "" ;
62+ return / m a c | i p h o n e | i p a d | i p o d / i. test ( uaPlatform ) || / M a c O S X | M a c i n t o s h | i P h o n e | i P a d | i P o d / i. test ( ua ) ;
63+ } , [ ] ) ;
64+
65+ const keyHintLabel = isApplePlatform ? "⌘K" : "Ctrl+K" ;
66+
67+ useEffect ( ( ) => {
68+ const onKeyDown = ( e ) => {
69+ const key = ( e . key || "" ) . toLowerCase ( ) ;
70+ if ( key !== "k" ) return ;
71+
72+ const comboPressed = isApplePlatform ? e . metaKey : e . ctrlKey ;
73+ if ( ! comboPressed ) return ;
74+
75+ e . preventDefault ( ) ;
76+ e . stopPropagation ( ) ;
77+
78+ const el = searchInputRef . current ;
79+ if ( ! el ) return ;
80+ el . focus ( ) ;
81+ if ( typeof el . select === "function" ) el . select ( ) ;
82+ } ;
83+
84+ window . addEventListener ( "keydown" , onKeyDown , { capture : true } ) ;
85+ return ( ) => window . removeEventListener ( "keydown" , onKeyDown , { capture : true } ) ;
86+ } , [ isApplePlatform ] ) ;
87+
88+ const toggleGroup = ( groupName ) => {
89+ toggleMenuGroupOpen ( groupName ) ;
90+ } ;
91+
92+ const isGroupOpen = ( groupName ) => ! ! groupOpenState [ groupName ] ;
5893
5994 const handleLinkClick = ( ) => {
6095 if ( isMobile ) {
@@ -122,6 +157,8 @@ export default function MenuContent({ navigationTree, isMobile }) {
122157 size = "small"
123158 fullWidth
124159 placeholder = "Search..."
160+ name = "crcon-search"
161+ inputRef = { searchInputRef }
125162 value = { searchTerm }
126163 onChange = { ( e ) => setSearchTerm ( e . target . value ) }
127164 sx = { { mb : 1 } }
@@ -132,6 +169,40 @@ export default function MenuContent({ navigationTree, isMobile }) {
132169 < SearchIcon fontSize = "small" />
133170 </ InputAdornment >
134171 ) ,
172+ endAdornment : (
173+ < InputAdornment position = "end" >
174+ < Box
175+ aria-hidden
176+ onMouseDown = { ( e ) => {
177+ // Keep focus in the input when clicking the hint.
178+ e . preventDefault ( ) ;
179+ searchInputRef . current ?. focus ?. ( ) ;
180+ } }
181+ sx = { {
182+ display : "inline-flex" ,
183+ alignItems : "center" ,
184+ gap : 0.75 ,
185+ px : 0.75 ,
186+ py : 0.25 ,
187+ borderRadius : 1 ,
188+ border : "1px solid" ,
189+ borderColor : "divider" ,
190+ bgcolor : ( theme ) => theme . palette . background . paper ,
191+ color : "text.secondary" ,
192+ fontSize : 12 ,
193+ lineHeight : 1 ,
194+ fontFamily :
195+ 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' ,
196+ letterSpacing : 0.2 ,
197+ userSelect : "none" ,
198+ boxShadow : ( theme ) =>
199+ `inset 0 1px 0 ${ theme . palette . action . hover } , inset 0 -1px 0 ${ theme . palette . action . selected } ` ,
200+ } }
201+ >
202+ { keyHintLabel }
203+ </ Box >
204+ </ InputAdornment >
205+ ) ,
135206 }
136207 } }
137208 />
@@ -159,7 +230,14 @@ export default function MenuContent({ navigationTree, isMobile }) {
159230 { filteredTree
160231 . filter ( ( group ) => "name" in group )
161232 . map ( ( group ) => (
162- < Group key = { group . name } groupName = { group . name } icon = { group . icon } >
233+ < Group
234+ key = { group . name }
235+ groupName = { group . name }
236+ icon = { group . icon }
237+ open = { isGroupOpen ( group . name ) }
238+ forceOpen = { ! ! searchTerm . trim ( ) }
239+ onToggle = { ( ) => toggleGroup ( group . name ) }
240+ >
163241 { group . links . map ( ( link ) => (
164242 < NavigationLink
165243 key = { link . to }
0 commit comments