@@ -17,7 +17,8 @@ import {
1717 PanelLeft ,
1818 Building2 ,
1919 Globe ,
20- FileWarning
20+ FileWarning ,
21+ ChevronRight
2122} from 'lucide-react'
2223import { createContext , useContext , useEffect , useState , useMemo , useRef , useCallback } from 'react'
2324import { usePDMStore , SidebarView } from '../stores/pdmStore'
@@ -28,7 +29,9 @@ import { logNavigation, logSettings } from '../lib/userActionLogger'
2829import {
2930 MODULES ,
3031 isModuleVisible ,
31- type ModuleId
32+ getChildModules ,
33+ type ModuleId ,
34+ type ModuleDefinition
3235} from '../types/modules'
3336
3437// Custom Google Drive icon
@@ -73,31 +76,69 @@ interface ActivityItemProps {
7376 view : SidebarView
7477 title : string
7578 badge ?: number
79+ hasChildren ?: boolean
80+ children ?: ModuleDefinition [ ]
81+ depth ?: number
82+ onHoverWithChildren ?: ( moduleId : ModuleId | null , rect : DOMRect | null ) => void
7683}
7784
78- function ActivityItem ( { icon, view, title, badge } : ActivityItemProps ) {
85+ function ActivityItem ( { icon, view, title, badge, hasChildren , children , depth = 0 , onHoverWithChildren } : ActivityItemProps ) {
7986 const { activeView, setActiveView } = usePDMStore ( )
8087 const isExpanded = useContext ( ExpandedContext )
8188 const [ showTooltip , setShowTooltip ] = useState ( false )
89+ const [ showSubmenu , setShowSubmenu ] = useState ( false )
90+ const [ submenuRect , setSubmenuRect ] = useState < DOMRect | null > ( null )
91+ const buttonRef = useRef < HTMLButtonElement > ( null )
8292 const isActive = activeView === view
93+ const hoverTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
94+
95+ const handleMouseEnter = ( ) => {
96+ if ( ! isExpanded ) setShowTooltip ( true )
97+
98+ if ( hasChildren && children && children . length > 0 ) {
99+ // Delay showing submenu slightly to prevent accidental triggers
100+ hoverTimeoutRef . current = setTimeout ( ( ) => {
101+ setShowSubmenu ( true )
102+ if ( buttonRef . current ) {
103+ setSubmenuRect ( buttonRef . current . getBoundingClientRect ( ) )
104+ }
105+ onHoverWithChildren ?.( view as ModuleId , buttonRef . current ?. getBoundingClientRect ( ) || null )
106+ } , 150 )
107+ }
108+ }
109+
110+ const handleMouseLeave = ( ) => {
111+ setShowTooltip ( false )
112+ if ( hoverTimeoutRef . current ) {
113+ clearTimeout ( hoverTimeoutRef . current )
114+ hoverTimeoutRef . current = null
115+ }
116+ // Don't close submenu immediately - let the submenu handle its own hover
117+ setTimeout ( ( ) => {
118+ setShowSubmenu ( false )
119+ onHoverWithChildren ?.( null , null )
120+ } , 100 )
121+ }
83122
84123 return (
85124 < div className = "py-1 px-[6px]" >
86125 < button
126+ ref = { buttonRef }
87127 onClick = { ( ) => {
128+ // If has children, clicking still navigates to the main view
88129 logNavigation ( view , { title } )
89130 setActiveView ( view )
90131 } }
91- onMouseEnter = { ( ) => ! isExpanded && setShowTooltip ( true ) }
92- onMouseLeave = { ( ) => setShowTooltip ( false ) }
132+ onMouseEnter = { handleMouseEnter }
133+ onMouseLeave = { handleMouseLeave }
93134 className = { `relative h-11 w-full flex items-center gap-3 px-[9px] rounded-lg transition-colors overflow-hidden ${
94135 isActive
95136 ? 'text-plm-accent bg-plm-highlight'
96137 : 'text-plm-fg-dim hover:text-plm-fg hover:bg-plm-highlight'
97138 } `}
98139 >
99140 { /* Tooltip for collapsed state */ }
100- { showTooltip && ! isExpanded && (
141+ { showTooltip && ! isExpanded && ! hasChildren && (
101142 < div className = "absolute left-full ml-3 z-50 pointer-events-none" >
102143 < div className = "px-2.5 py-1.5 bg-plm-fg text-plm-bg text-sm font-medium rounded whitespace-nowrap" >
103144 { title }
@@ -115,11 +156,130 @@ function ActivityItem({ icon, view, title, badge }: ActivityItemProps) {
115156 ) }
116157 </ div >
117158 { isExpanded && (
118- < span className = "text-[15px] font-medium whitespace-nowrap overflow-hidden" >
119- { title }
120- </ span >
159+ < >
160+ < span className = "text-[15px] font-medium whitespace-nowrap overflow-hidden flex-1" >
161+ { title }
162+ </ span >
163+ { /* Chevron for items with children */ }
164+ { hasChildren && children && children . length > 0 && (
165+ < ChevronRight
166+ size = { 14 }
167+ className = { `flex-shrink-0 text-plm-fg-dim transition-transform duration-200 ${ showSubmenu ? 'translate-x-0.5' : '' } ` }
168+ />
169+ ) }
170+ </ >
121171 ) }
122172 </ button >
173+
174+ { /* Nested Submenu Panel */ }
175+ { showSubmenu && hasChildren && children && children . length > 0 && submenuRect && (
176+ < NestedSubmenu
177+ parentRect = { submenuRect }
178+ children = { children }
179+ depth = { depth + 1 }
180+ onMouseEnter = { ( ) => setShowSubmenu ( true ) }
181+ onMouseLeave = { ( ) => setShowSubmenu ( false ) }
182+ />
183+ ) }
184+ </ div >
185+ )
186+ }
187+
188+ // Nested submenu component that appears on hover
189+ interface NestedSubmenuProps {
190+ parentRect : DOMRect
191+ children : ModuleDefinition [ ]
192+ depth : number
193+ onMouseEnter : ( ) => void
194+ onMouseLeave : ( ) => void
195+ }
196+
197+ function NestedSubmenu ( { parentRect, children, depth, onMouseEnter, onMouseLeave } : NestedSubmenuProps ) {
198+ const { activeView, setActiveView, moduleConfig } = usePDMStore ( )
199+ const { t } = useTranslation ( )
200+ const [ hoveredChild , setHoveredChild ] = useState < ModuleId | null > ( null )
201+ const [ childRect , setChildRect ] = useState < DOMRect | null > ( null )
202+ const panelRef = useRef < HTMLDivElement > ( null )
203+
204+ // Filter to only show visible children
205+ const visibleChildren = children . filter ( child => isModuleVisible ( child . id , moduleConfig ) )
206+
207+ if ( visibleChildren . length === 0 ) return null
208+
209+ // Calculate position - always to the right of parent
210+ const style : React . CSSProperties = {
211+ position : 'fixed' ,
212+ top : parentRect . top - 4 ,
213+ left : parentRect . right + 4 ,
214+ zIndex : 50 + depth ,
215+ }
216+
217+ return (
218+ < div
219+ ref = { panelRef }
220+ style = { style }
221+ className = "min-w-[180px] bg-plm-activitybar border border-plm-border rounded-lg shadow-xl py-1 animate-in fade-in slide-in-from-left-2 duration-150"
222+ onMouseEnter = { onMouseEnter }
223+ onMouseLeave = { onMouseLeave }
224+ >
225+ { visibleChildren . map ( child => {
226+ const childChildren = getChildModules ( child . id , moduleConfig ) . filter ( c => isModuleVisible ( c . id , moduleConfig ) )
227+ const hasGrandchildren = childChildren . length > 0
228+ const translationKey = moduleTranslationKeys [ child . id ]
229+ const childTitle = translationKey ? t ( translationKey ) : child . name
230+ const isActive = activeView === child . id
231+
232+ return (
233+ < div
234+ key = { child . id }
235+ className = "relative"
236+ onMouseEnter = { ( e ) => {
237+ if ( hasGrandchildren ) {
238+ setHoveredChild ( child . id )
239+ const target = e . currentTarget
240+ setChildRect ( target . getBoundingClientRect ( ) )
241+ }
242+ } }
243+ onMouseLeave = { ( ) => {
244+ setTimeout ( ( ) => setHoveredChild ( null ) , 100 )
245+ } }
246+ >
247+ < button
248+ onClick = { ( ) => {
249+ logNavigation ( child . id , { title : childTitle } )
250+ setActiveView ( child . id as SidebarView )
251+ } }
252+ className = { `w-full h-10 flex items-center gap-3 px-3 rounded-md mx-1 transition-colors ${
253+ isActive
254+ ? 'text-plm-accent bg-plm-highlight'
255+ : 'text-plm-fg-dim hover:text-plm-fg hover:bg-plm-highlight'
256+ } `}
257+ style = { { width : 'calc(100% - 8px)' } }
258+ >
259+ < div className = "w-[18px] h-[18px] flex items-center justify-center flex-shrink-0" >
260+ { getModuleIcon ( child . icon , 18 ) }
261+ </ div >
262+ < span className = "text-sm font-medium whitespace-nowrap overflow-hidden flex-1 text-left" >
263+ { childTitle }
264+ </ span >
265+ { hasGrandchildren && (
266+ < ChevronRight size = { 12 } className = "flex-shrink-0 text-plm-fg-dim" />
267+ ) }
268+ </ button >
269+
270+ { /* Nested children (recursive) */ }
271+ { hoveredChild === child . id && hasGrandchildren && childRect && (
272+ < NestedSubmenu
273+ parentRect = { childRect }
274+ children = { childChildren }
275+ depth = { depth + 1 }
276+ onMouseEnter = { ( ) => setHoveredChild ( child . id ) }
277+ onMouseLeave = { ( ) => setHoveredChild ( null ) }
278+ />
279+ ) }
280+ </ div >
281+ )
282+ } ) }
123283 </ div >
124284 )
125285}
@@ -287,10 +447,14 @@ export function ActivityBar() {
287447 const totalBadge = unreadNotificationCount + pendingReviewCount
288448
289449 // Build the visible modules list based on module order and visibility
450+ // Only show top-level modules (those without a parent)
290451 const visibleModules = useMemo ( ( ) => {
291- return moduleConfig . moduleOrder . filter ( moduleId =>
292- isModuleVisible ( moduleId , moduleConfig )
293- )
452+ return moduleConfig . moduleOrder . filter ( moduleId => {
453+ const module = MODULES . find ( m => m . id === moduleId )
454+ // Only show if visible AND is top-level (no parent in config)
455+ const hasParent = moduleConfig . moduleParents ?. [ moduleId ]
456+ return module && ! hasParent && isModuleVisible ( moduleId , moduleConfig )
457+ } )
294458 } , [ moduleConfig ] )
295459
296460 // Build a map of original index to visible index for divider positioning
@@ -403,13 +567,21 @@ export function ActivityBar() {
403567 // Special handling for reviews badge
404568 const badge = moduleId === 'reviews' ? totalBadge : undefined
405569
570+ // Get visible child modules (using config's moduleParents)
571+ const childModules = getChildModules ( moduleId , moduleConfig ) . filter ( child =>
572+ isModuleVisible ( child . id , moduleConfig )
573+ )
574+ const moduleHasChildren = childModules . length > 0
575+
406576 return (
407577 < div key = { moduleId } >
408578 < ActivityItem
409579 icon = { getModuleIcon ( module . icon ) }
410580 view = { moduleId as SidebarView }
411581 title = { title }
412582 badge = { badge }
583+ hasChildren = { moduleHasChildren }
584+ children = { childModules }
413585 />
414586 { getDividerAfterVisibleIndex . has ( visibleIndex ) && < SectionDivider /> }
415587 </ div >
0 commit comments