Skip to content

Commit 952915a

Browse files
authored
fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus (#3588)
* fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus * fix(sidebar): truncate long names in collapsed dropdown menus * fix(sidebar): address PR review — extract components, fix reactive subscription * fix(sidebar): support touch/keyboard for collapsed menus, document auto-collapse * fix(sidebar): remove dead CSS selector for sidebar-collapse-remove * fix(sidebar): add aria-label to collapsed menu trigger buttons * fix(sidebar): use useLayoutEffect for attribute removal, remove dead branch
1 parent cbc9f42 commit 952915a

File tree

10 files changed

+437
-121
lines changed

10 files changed

+437
-121
lines changed

apps/sim/app/_styles/globals.css

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,26 @@
3333
opacity: 0;
3434
}
3535

36+
html[data-sidebar-collapsed] .sidebar-container span,
37+
html[data-sidebar-collapsed] .sidebar-container .text-small {
38+
opacity: 0;
39+
}
40+
3641
.sidebar-container .sidebar-collapse-hide {
3742
transition: opacity 60ms ease;
3843
}
3944

40-
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
45+
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
46+
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
47+
opacity: 0;
48+
}
49+
50+
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
51+
display: none;
52+
}
53+
54+
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
55+
width: 0;
4156
opacity: 0;
4257
}
4358

apps/sim/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
114114
115115
if (isCollapsed) {
116116
document.documentElement.style.setProperty('--sidebar-width', '51px');
117+
document.documentElement.setAttribute('data-sidebar-collapsed', '');
117118
} else {
118119
var width = state && state.sidebarWidth;
119120
var maxSidebarWidth = window.innerWidth * 0.3;

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
1717
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
1818
import type { ChatContext } from '@/stores/panel'
19+
import { useSidebarStore } from '@/stores/sidebar/store'
1920
import {
2021
MessageContent,
2122
MothershipView,
@@ -166,6 +167,9 @@ export function Home({ chatId }: HomeProps = {}) {
166167

167168
const handleResourceEvent = useCallback(() => {
168169
if (isResourceCollapsedRef.current) {
170+
/** Auto-collapse sidebar to give resource panel maximum width for immersive experience */
171+
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
172+
if (!isCollapsed) toggleCollapsed()
169173
setIsResourceCollapsed(false)
170174
setIsResourceAnimatingIn(true)
171175
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Folder } from 'lucide-react'
2+
import Link from 'next/link'
3+
import {
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuSub,
8+
DropdownMenuSubContent,
9+
DropdownMenuSubTrigger,
10+
DropdownMenuTrigger,
11+
} from '@/components/emcn'
12+
import { cn } from '@/lib/core/utils/cn'
13+
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
14+
import type { FolderTreeNode } from '@/stores/folders/types'
15+
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
16+
17+
interface CollapsedSidebarMenuProps {
18+
icon: React.ReactNode
19+
hover: ReturnType<typeof useHoverMenu>
20+
onClick?: () => void
21+
ariaLabel?: string
22+
children: React.ReactNode
23+
className?: string
24+
}
25+
26+
export function CollapsedSidebarMenu({
27+
icon,
28+
hover,
29+
onClick,
30+
ariaLabel,
31+
children,
32+
className,
33+
}: CollapsedSidebarMenuProps) {
34+
return (
35+
<div className={cn('flex flex-col px-[8px]', className)}>
36+
<DropdownMenu
37+
open={hover.isOpen}
38+
onOpenChange={(open) => {
39+
if (open) hover.open()
40+
else hover.close()
41+
}}
42+
modal={false}
43+
>
44+
<div {...hover.triggerProps}>
45+
<DropdownMenuTrigger asChild>
46+
<button
47+
type='button'
48+
aria-label={ariaLabel}
49+
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
50+
onClick={onClick}
51+
>
52+
{icon}
53+
</button>
54+
</DropdownMenuTrigger>
55+
</div>
56+
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
57+
{children}
58+
</DropdownMenuContent>
59+
</DropdownMenu>
60+
</div>
61+
)
62+
}
63+
64+
export function CollapsedFolderItems({
65+
nodes,
66+
workflowsByFolder,
67+
workspaceId,
68+
}: {
69+
nodes: FolderTreeNode[]
70+
workflowsByFolder: Record<string, WorkflowMetadata[]>
71+
workspaceId: string
72+
}) {
73+
return (
74+
<>
75+
{nodes.map((folder) => {
76+
const folderWorkflows = workflowsByFolder[folder.id] || []
77+
const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0
78+
79+
if (!hasChildren) {
80+
return (
81+
<DropdownMenuItem key={folder.id} disabled>
82+
<Folder className='h-[14px] w-[14px]' />
83+
<span className='truncate'>{folder.name}</span>
84+
</DropdownMenuItem>
85+
)
86+
}
87+
88+
return (
89+
<DropdownMenuSub key={folder.id}>
90+
<DropdownMenuSubTrigger>
91+
<Folder className='h-[14px] w-[14px]' />
92+
<span className='truncate'>{folder.name}</span>
93+
</DropdownMenuSubTrigger>
94+
<DropdownMenuSubContent>
95+
<CollapsedFolderItems
96+
nodes={folder.children}
97+
workflowsByFolder={workflowsByFolder}
98+
workspaceId={workspaceId}
99+
/>
100+
{folderWorkflows.map((workflow) => (
101+
<DropdownMenuItem key={workflow.id} asChild>
102+
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
103+
<div
104+
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
105+
style={{
106+
backgroundColor: workflow.color,
107+
borderColor: `${workflow.color}60`,
108+
backgroundClip: 'padding-box',
109+
}}
110+
/>
111+
<span className='truncate'>{workflow.name}</span>
112+
</Link>
113+
</DropdownMenuItem>
114+
))}
115+
</DropdownMenuSubContent>
116+
</DropdownMenuSub>
117+
)
118+
})}
119+
</>
120+
)
121+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export {
2+
CollapsedFolderItems,
3+
CollapsedSidebarMenu,
4+
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
15
export { HelpModal } from './help-modal/help-modal'
26
export { NavItemContextMenu } from './nav-item-context-menu'
37
export { SearchModal } from './search-modal/search-modal'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
useSidebarDragContextValue,
1414
useWorkflowSelection,
1515
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
16+
import {
17+
compareByOrder,
18+
groupWorkflowsByFolder,
19+
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
1620
import { useFolders } from '@/hooks/queries/folders'
1721
import { useFolderStore } from '@/stores/folders/store'
1822
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -22,17 +26,6 @@ const TREE_SPACING = {
2226
INDENT_PER_LEVEL: 20,
2327
} as const
2428

25-
function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
26-
a: T,
27-
b: T
28-
): number {
29-
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
30-
const timeA = a.createdAt?.getTime() ?? 0
31-
const timeB = b.createdAt?.getTime() ?? 0
32-
if (timeA !== timeB) return timeA - timeB
33-
return a.id.localeCompare(b.id)
34-
}
35-
3629
interface WorkflowListProps {
3730
workspaceId: string
3831
workflowId: string | undefined
@@ -129,21 +122,10 @@ export const WorkflowList = memo(function WorkflowList({
129122
return activeWorkflow?.folderId || null
130123
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
131124

132-
const workflowsByFolder = useMemo(() => {
133-
const grouped = regularWorkflows.reduce(
134-
(acc, workflow) => {
135-
const folderId = workflow.folderId || 'root'
136-
if (!acc[folderId]) acc[folderId] = []
137-
acc[folderId].push(workflow)
138-
return acc
139-
},
140-
{} as Record<string, WorkflowMetadata[]>
141-
)
142-
for (const folderId of Object.keys(grouped)) {
143-
grouped[folderId].sort(compareByOrder)
144-
}
145-
return grouped
146-
}, [regularWorkflows])
125+
const workflowsByFolder = useMemo(
126+
() => groupWorkflowsByFolder(regularWorkflows),
127+
[regularWorkflows]
128+
)
147129

148130
const orderedWorkflowIds = useMemo(() => {
149131
const ids: string[] = []

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { type DropIndicator, useDragDrop } from './use-drag-drop'
44
export { useFolderExpand } from './use-folder-expand'
55
export { useFolderOperations } from './use-folder-operations'
66
export { useFolderSelection } from './use-folder-selection'
7+
export { useHoverMenu } from './use-hover-menu'
78
export { useItemDrag } from './use-item-drag'
89
export { useItemRename } from './use-item-rename'
910
export {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
3+
const CLOSE_DELAY_MS = 150
4+
5+
const preventAutoFocus = (e: Event) => e.preventDefault()
6+
7+
/**
8+
* Manages hover-triggered dropdown menu state.
9+
* Provides handlers for trigger and content mouse events with a delay
10+
* to prevent flickering when moving between trigger and content.
11+
*/
12+
export function useHoverMenu() {
13+
const [isOpen, setIsOpen] = useState(false)
14+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15+
16+
const cancelClose = useCallback(() => {
17+
if (closeTimerRef.current) {
18+
clearTimeout(closeTimerRef.current)
19+
closeTimerRef.current = null
20+
}
21+
}, [])
22+
23+
useEffect(() => {
24+
return () => {
25+
if (closeTimerRef.current) {
26+
clearTimeout(closeTimerRef.current)
27+
}
28+
}
29+
}, [])
30+
31+
const scheduleClose = useCallback(() => {
32+
cancelClose()
33+
closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
34+
}, [cancelClose])
35+
36+
const open = useCallback(() => {
37+
cancelClose()
38+
setIsOpen(true)
39+
}, [cancelClose])
40+
41+
const close = useCallback(() => {
42+
cancelClose()
43+
setIsOpen(false)
44+
}, [cancelClose])
45+
46+
const triggerProps = useMemo(
47+
() => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
48+
[open, scheduleClose]
49+
)
50+
51+
const contentProps = useMemo(
52+
() =>
53+
({
54+
onMouseEnter: cancelClose,
55+
onMouseLeave: scheduleClose,
56+
onCloseAutoFocus: preventAutoFocus,
57+
}) as const,
58+
[cancelClose, scheduleClose]
59+
)
60+
61+
return { isOpen, open, close, triggerProps, contentProps }
62+
}

0 commit comments

Comments
 (0)