From 6851334f8ebf9a8f73f19c00c89e942209a4a5dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 20:50:30 -0700 Subject: [PATCH] fix(sidebar): extend faded labels until row actions appear --- .../chats-section/chats-section.tsx | 47 +++++----- .../workspaces-section/workspace-list.tsx | 38 ++++---- .../w/[workflowId]/components/panel/panel.tsx | 90 ++++++++----------- .../collapsed-sidebar-menu.tsx | 5 +- .../w/components/sidebar/components/index.ts | 1 + .../components/sidebar-row-actions.tsx | 51 +++++++++++ .../components/folder-item/folder-item.tsx | 55 ++++++------ .../workflow-item/workflow-item.tsx | 47 ++++------ .../workspace-header/workspace-header.tsx | 1 + .../w/components/sidebar/sidebar.tsx | 55 +++++------- .../dropdown-menu/dropdown-menu.test.tsx | 73 ++++++++++++++- .../dropdown-menu/dropdown-menu.tsx | 74 +++++++++++++-- 12 files changed, 342 insertions(+), 195 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index f83504cecae..2915b67c981 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -8,6 +8,7 @@ import { ChatNavigationLink, CollapsedChatFlyoutItem, CollapsedSidebarMenu, + SidebarRowActions, SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' @@ -56,30 +57,27 @@ function ChatRow({ href={chat.href} chatId={chat.id} isCurrentRoute={isCurrentRoute} - className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })} + className={cn( + chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true }), + 'group/sidebar-row' + )} onContextMenu={(e) => onContextMenu(e, chat.id)} > -
- {showStatusDot && ( -
+ ) } diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index 8d1bfa0b942..faae4ebd437 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -18,6 +18,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context- import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' +import { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' import { useToggleWorkspacePin, useUpdateWorkspace } from '@/hooks/queries/workspace' @@ -139,6 +140,7 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis key={workspace.id} asChild active={isActive || isMenuOpen} + actionOpen={isMenuOpen} onPointerMove={(event) => { if (menu.isOpen || rename.editingId) event.preventDefault() }} @@ -176,34 +178,36 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis openMenu(event, workspace.id)} > {label} -
- {isPinned && ( - - )} + + ) : undefined + } + > -
+
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index f1790e469f5..726bfbb02a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -10,17 +10,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemAction, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Duplicate, Layout, MoreHorizontal, - Popover, - PopoverContent, - PopoverItem, - PopoverScrollArea, - PopoverSection, - PopoverTrigger, Trash, toast, } from '@sim/emcn' @@ -913,63 +909,55 @@ export const Panel = memo(function Panel() { - { setIsCopilotHistoryOpen(open) if (open) loadCopilotChats() }} > - - - - + + {copilotChatList.length === 0 ? ( -
- No chats yet -
+ No chats yet ) : ( - - Recent -
- {copilotChatList.map((chat) => ( -
- handleCopilotSelectChat(chat)} + <> + Recent + {copilotChatList.map((chat) => ( + handleCopilotSelectChat(chat)} + action={ + handleCopilotDeleteChat(chat.id)} > - - -
- } - /> - -
- ))} - -
+ + + } + > + + + ))} + )} -
-
+ + diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 04c633b19fe..d3cbc7e1eb7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -257,6 +257,7 @@ export function CollapsedSidebarMenu({ onMoreClick?.(e, chat.id)} - className={cn(isMenuOpen && 'opacity-100')} > @@ -409,6 +410,7 @@ export function CollapsedWorkflowFlyoutItem({ setActionsOpen((prev) => !prev)} - className={cn(actionsOpen && 'opacity-100')} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index e735e674e89..583f7b5aeca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -1,3 +1,4 @@ +export { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' export { ChatNavigationLink } from './chat-navigation-link/chat-navigation-link' export { CollapsedChatFlyoutItem, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx new file mode 100644 index 00000000000..1d966c4102a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface SidebarRowActionsProps { + children: ReactNode + indicator?: ReactNode + open?: boolean + revealOnHover?: boolean +} + +/** Reclaims idle action space while retaining indicators and touch/keyboard access. */ +export function SidebarRowActions({ + children, + indicator, + open = false, + revealOnHover = true, +}: SidebarRowActionsProps) { + return ( +
+ {indicator && ( + + {indicator} + + )} +
+ {children} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 914643aeba4..67a8bba50fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -17,6 +17,7 @@ import { useRouter } from 'next/navigation' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { generateSubfolderName } from '@/lib/workspaces/naming' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { @@ -385,16 +386,13 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold [handleToggleExpanded, shouldPreventClickRef, isEditing, onFolderClick, folder.id] ) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (isEditing) { - handleRenameKeyDown(e) - } else { - handleExpandKeyDown(e) - } - }, - [isEditing, handleRenameKeyDown, handleExpandKeyDown] - ) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isEditing) { + handleRenameKeyDown(e) + } else if (e.target === e.currentTarget) { + handleExpandKeyDown(e) + } + } const handleMorePointerDown = useCallback(() => { if (isContextMenuOpen) { @@ -503,6 +501,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold aria-label={`${folder.name} folder, ${isExpanded ? 'expanded' : 'collapsed'}`} className={cn( chipVariants({ active: isSelected || isContextMenuOpen, fullWidth: true }), + 'group/sidebar-row', (isDragging || (isAnyDragActive && isSelected)) && 'opacity-50' )} onClick={handleFolderSelect} @@ -548,34 +547,30 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold > -
- {folder.locked && ( - - - )} + + ) : undefined + } + > -
+ )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 734aefef876..08c60fd1a81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -6,6 +6,7 @@ import { Lock, MoreHorizontal } from '@sim/emcn/icons' import Link from 'next/link' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars' @@ -410,6 +411,7 @@ export const WorkflowItem = memo(function WorkflowItem({ active: active || isContextMenuOpen || (isSelected && selectedWorkflows.size > 1), fullWidth: true, }), + 'group/sidebar-row', (isDragging || (isAnyDragActive && isSelected)) && 'opacity-50' )} draggable={!isEditing && !dragDisabled && !effectiveLocked} @@ -448,43 +450,30 @@ export const WorkflowItem = memo(function WorkflowItem({ {!isEditing && ( -
- {workflow.locked && ( - - - )} + + ) : undefined + } + > -
+ )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 6d083c20c63..5053a95637e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -694,6 +694,7 @@ function WorkspaceHeaderImpl({ ) : ( event.preventDefault()} onPointerMove={preserveSearchFocus} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 44f0619a5c6..98b1c4a670c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -77,6 +77,7 @@ import { SidebarFooter, SidebarNavChip, type SidebarNavItemData, + SidebarRowActions, SidebarSection, SidebarTooltip, StatusNotice, @@ -249,10 +250,13 @@ const SidebarChatItem = memo(function SidebarChatItem({ chatId={chat.id} href={chat.href} isCurrentRoute={isCurrentRoute} - className={chipVariants({ - active: isCurrentRoute || isSelected || isMenuOpen, - fullWidth: true, - })} + className={cn( + chipVariants({ + active: isCurrentRoute || isSelected || isMenuOpen, + fullWidth: true, + }), + 'group/sidebar-row' + )} onClick={(e) => { if (e.metaKey || e.ctrlKey) return if (e.shiftKey) { @@ -269,28 +273,20 @@ const SidebarChatItem = memo(function SidebarChatItem({ > {chat.id !== 'new' && ( -
- {showStatusDot && ( -
+ )} diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx index 94145633802..d810dfb4472 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -10,12 +10,13 @@ */ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemAction, DropdownMenuItemLabel, DropdownMenuTrigger, } from './dropdown-menu' @@ -129,3 +130,73 @@ describe('menu row labels', () => { expect(label?.className).not.toContain('truncate') }) }) + +describe('menu row actions', () => { + it('moves focus to an action and back without selecting the row', () => { + const onSelect = vi.fn() + openMenu( + More} + > + A workflow + + ) + const item = row() + const action = row('button[aria-label="Options"]') + act(() => { + item.focus() + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + }) + expect(document.activeElement).toBe(action) + act(() => { + action.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })) + }) + expect(document.activeElement).toBe(item) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('invokes an action without navigating its asChild row or closing the menu', () => { + const onSelect = vi.fn() + const onAction = vi.fn() + openMenu( + + More + + } + > + A workflow + + ) + act(() => row('button[aria-label="Options"]').click()) + expect(onAction).toHaveBeenCalledOnce() + expect(onSelect).not.toHaveBeenCalled() + expect(row('a').getAttribute('href')).toBe('#workflow') + expect(document.querySelector('[role="menu"]')).not.toBeNull() + }) + + it('keeps focus on the row when its action is disabled', () => { + openMenu( + + More + + } + > + A workflow + + ) + const item = row() + act(() => { + item.focus() + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + }) + expect(document.activeElement).toBe(item) + }) +}) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 8417f9e6208..6fea1973ce2 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -327,28 +327,75 @@ const DropdownMenuItem = React.forwardRef< /** * Optional inline action rendered on the right edge of the item — e.g. a * "more" icon button. Reveals on hover/focus of the row, and the row stays - * highlighted while the cursor is over the action. + * highlighted while the cursor is over the action. ArrowRight moves from + * the row to its action; ArrowLeft returns to the row. */ action?: React.ReactNode - /** Idle indicator sharing the action slot so the label never shifts. */ + /** Keeps the action visible while its portaled menu is open. */ + actionOpen?: boolean + /** Idle indicator sharing the action slot on hover-capable devices. */ actionIndicator?: React.ReactNode } >( ( - { className, size, inset, active, action, actionIndicator, asChild, children, ...props }, + { + className, + size, + inset, + active, + action, + actionOpen, + actionIndicator, + asChild, + children, + ...props + }, ref ) => { + const actionRef = React.useRef(null) const content = asChild ? children : withOverflowLabel(children) const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS if (action) { return ( -
+
{ + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return + const row = event.currentTarget.firstElementChild + const actionButton = + actionRef.current?.querySelector('button:not(:disabled)') + if (event.key === 'ArrowRight' && event.target === row && actionButton) { + event.preventDefault() + event.stopPropagation() + actionButton.focus() + } else if ( + event.key === 'ArrowLeft' && + row instanceof HTMLElement && + event.target instanceof Node && + actionRef.current?.contains(event.target) + ) { + event.preventDefault() + event.stopPropagation() + row.focus() + } + }} + > {content} -
+
{actionIndicator && ( -
+
{actionIndicator}
)} -
+
{action}