11import React , { useCallback , useEffect , useLayoutEffect , useRef , useState } from 'react' ;
22import ReactDOM from 'react-dom' ;
33
4- export type MenuItem = {
5- label : string ;
6- action : ( ) => void ;
7- icon ?: React . FC < { className ?: string } > ;
8- disabled ?: boolean ;
9- shortcut ?: string ;
10- } | { type : 'separator' } ;
4+ export type MenuItem =
5+ | {
6+ label : string ;
7+ action : ( ) => void ;
8+ icon ?: React . FC < { className ?: string } > ;
9+ disabled ?: boolean ;
10+ shortcut ?: string ;
11+ submenu ?: never ;
12+ }
13+ | {
14+ label : string ;
15+ submenu : MenuItem [ ] ;
16+ icon ?: React . FC < { className ?: string } > ;
17+ disabled ?: boolean ;
18+ shortcut ?: string ;
19+ action ?: never ;
20+ }
21+ | { type : 'separator' } ;
1122
1223interface ContextMenuProps {
1324 isOpen : boolean ;
@@ -20,6 +31,7 @@ const EDGE_MARGIN = 8;
2031
2132const ContextMenu : React . FC < ContextMenuProps > = ( { isOpen, position, items, onClose } ) => {
2233 const menuRef = useRef < HTMLDivElement > ( null ) ;
34+ const [ openSubmenu , setOpenSubmenu ] = useState < string | null > ( null ) ;
2335 const [ menuStyle , setMenuStyle ] = useState < { top : number ; left : number ; maxHeight : number ; overflowY : React . CSSProperties [ 'overflowY' ] } > ( {
2436 top : position . y ,
2537 left : position . x ,
@@ -76,6 +88,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
7688 useEffect ( ( ) => {
7789 if ( ! isOpen ) return ;
7890
91+ setOpenSubmenu ( null ) ;
92+
7993 const handleClickOutside = ( event : MouseEvent ) => {
8094 if ( menuRef . current && ! menuRef . current . contains ( event . target as Node ) ) {
8195 onClose ( ) ;
@@ -146,6 +160,88 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
146160 const overlayRoot = document . getElementById ( 'overlay-root' ) ;
147161 if ( ! overlayRoot ) return null ;
148162
163+ const hasEnabledItem = ( menuItems : MenuItem [ ] ) : boolean => {
164+ return menuItems . some ( item => {
165+ if ( 'type' in item ) {
166+ return false ;
167+ }
168+
169+ if ( 'submenu' in item ) {
170+ return ! item . disabled && hasEnabledItem ( item . submenu ) ;
171+ }
172+
173+ return ! item . disabled ;
174+ } ) ;
175+ } ;
176+
177+ const renderItems = ( menuItems : MenuItem [ ] , depth = 0 ) => {
178+ return menuItems . map ( ( item , index ) => {
179+ if ( 'type' in item ) {
180+ return < li key = { `separator-${ depth } -${ index } ` } className = "h-px bg-border-color my-1.5" /> ;
181+ }
182+
183+ if ( 'submenu' in item ) {
184+ const menuKey = `${ depth } -${ index } ` ;
185+ const isOpen = openSubmenu === menuKey ;
186+ const hasEnabledSubitem = hasEnabledItem ( item . submenu ) ;
187+ const Icon = item . icon ;
188+
189+ return (
190+ < li
191+ key = { `${ item . label } -${ depth } -${ index } ` }
192+ className = "relative"
193+ onMouseEnter = { ( ) => setOpenSubmenu ( menuKey ) }
194+ onMouseLeave = { ( ) => setOpenSubmenu ( null ) }
195+ >
196+ < button
197+ onClick = { ( ) => {
198+ if ( ! item . disabled && hasEnabledSubitem ) {
199+ setOpenSubmenu ( menuKey ) ;
200+ }
201+ } }
202+ disabled = { item . disabled || ! hasEnabledSubitem }
203+ className = "w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
204+ >
205+ < div className = "flex items-center gap-3" >
206+ { Icon && < Icon className = "w-4 h-4" /> }
207+ < span > { item . label } </ span >
208+ </ div >
209+ < span className = "text-text-secondary" > ›</ span >
210+ </ button >
211+ { isOpen && item . submenu . length > 0 && (
212+ < div className = "absolute top-0 left-full ml-1 z-10 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast" >
213+ < ul className = "space-y-1" > { renderItems ( item . submenu , depth + 1 ) } </ ul >
214+ </ div >
215+ ) }
216+ </ li >
217+ ) ;
218+ }
219+
220+ const { label, action, icon : Icon , disabled, shortcut } = item ;
221+
222+ return (
223+ < li key = { `${ label } -${ depth } -${ index } ` } >
224+ < button
225+ onClick = { ( ) => {
226+ if ( ! disabled ) {
227+ action ( ) ;
228+ onClose ( ) ;
229+ }
230+ } }
231+ disabled = { disabled }
232+ className = "w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
233+ >
234+ < div className = "flex items-center gap-3" >
235+ { Icon && < Icon className = "w-4 h-4" /> }
236+ < span > { label } </ span >
237+ </ div >
238+ { shortcut && < span className = "text-xs text-text-secondary" > { shortcut } </ span > }
239+ </ button >
240+ </ li >
241+ ) ;
242+ } ) ;
243+ } ;
244+
149245 return ReactDOM . createPortal (
150246 < div
151247 ref = { menuRef }
@@ -158,30 +254,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
158254 className = "fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast"
159255 >
160256 < ul className = "space-y-1" >
161- { /* Fix: Restructured the type guard to check for a property on the desired object type directly, which ensures proper type narrowing for the MenuItem union. */ }
162- { items . map ( ( item , index ) => {
163- if ( 'label' in item ) {
164- const { label, action, icon : Icon , disabled, shortcut } = item ;
165-
166- return (
167- < li key = { label } >
168- < button
169- onClick = { ( ) => { if ( ! disabled ) { action ( ) ; onClose ( ) ; } } }
170- disabled = { disabled }
171- className = "w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
172- >
173- < div className = "flex items-center gap-3" >
174- { Icon && < Icon className = "w-4 h-4" /> }
175- < span > { label } </ span >
176- </ div >
177- { shortcut && < span className = "text-xs text-text-secondary" > { shortcut } </ span > }
178- </ button >
179- </ li >
180- ) ;
181- } else {
182- return < li key = { `separator-${ index } ` } className = "h-px bg-border-color my-1.5" /> ;
183- }
184- } ) }
257+ { renderItems ( items ) }
185258 </ ul >
186259 < style > { `
187260 @keyframes fade-in-fast {
0 commit comments