Skip to content

Commit 1fe94d3

Browse files
authored
feat(sidebar): add Cmd+B shortcut to toggle sidebar collapse (#5618)
* feat(sidebar): add Cmd+B shortcut to toggle sidebar collapse * fix(sidebar): skip Cmd+B sidebar toggle inside editable fields * fix(sidebar): don't dead-zone Cmd+B on read-only editors; drop stale Mod+B ownership * chore(lint): apply biome fixes
1 parent f477faa commit 1fe94d3

5 files changed

Lines changed: 177 additions & 106 deletions

File tree

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function RegisterModK({ handler }: { handler: () => void }) {
1919
return null
2020
}
2121

22+
function RegisterModKOutsideEditable({ handler }: { handler: () => void }) {
23+
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler, allowInEditable: false }])
24+
return null
25+
}
26+
2227
let container: HTMLDivElement
2328
let root: Root
2429

@@ -87,3 +92,45 @@ describe('GlobalCommandsProvider owned-shortcut yielding', () => {
8792
expect(handler).toHaveBeenCalledTimes(1)
8893
})
8994
})
95+
96+
describe('GlobalCommandsProvider editable guard', () => {
97+
it('skips a non-editable command when focus is in an input', () => {
98+
const handler = vi.fn()
99+
mount(
100+
<GlobalCommandsProvider>
101+
<RegisterModKOutsideEditable handler={handler} />
102+
<input type='text' />
103+
</GlobalCommandsProvider>
104+
)
105+
;(container.querySelector('input') as HTMLInputElement).focus()
106+
pressModK()
107+
expect(handler).not.toHaveBeenCalled()
108+
})
109+
110+
it('skips a non-editable command when focus is in a contenteditable element', () => {
111+
const handler = vi.fn()
112+
mount(
113+
<GlobalCommandsProvider>
114+
<RegisterModKOutsideEditable handler={handler} />
115+
<div contentEditable />
116+
</GlobalCommandsProvider>
117+
)
118+
;(container.querySelector('[contenteditable]') as HTMLElement).focus()
119+
pressModK()
120+
expect(handler).toHaveBeenCalledTimes(0)
121+
})
122+
123+
it('fires a non-editable command when focus is in a contenteditable="false" element', () => {
124+
const handler = vi.fn()
125+
mount(
126+
<GlobalCommandsProvider>
127+
<RegisterModKOutsideEditable handler={handler} />
128+
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a read-only editor */}
129+
<div contentEditable={false} tabIndex={0} />
130+
</GlobalCommandsProvider>
131+
)
132+
;(container.querySelector('[contenteditable]') as HTMLElement).focus()
133+
pressModK()
134+
expect(handler).toHaveBeenCalledTimes(1)
135+
})
136+
})

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
9393
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
9494
}
9595

96+
/**
97+
* Whether `element` is an editable region for the purposes of the editable guard.
98+
* `contenteditable="false"` (e.g. a rich-text editor in read-only mode) is not editable,
99+
* so commands with `allowInEditable: false` still fire while such an element has focus.
100+
*/
101+
function isEditableElement(element: Element | null): boolean {
102+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true
103+
if (!(element instanceof HTMLElement)) return false
104+
const contentEditable = element.getAttribute('contenteditable')
105+
return contentEditable !== null && contentEditable.toLowerCase() !== 'false'
106+
}
107+
96108
/**
97109
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
98110
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
@@ -141,14 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
141153
if (e.isComposing) return
142154

143155
for (const [, cmd] of registryRef.current) {
144-
if (!cmd.allowInEditable) {
145-
const ae = document.activeElement
146-
const isEditable =
147-
ae instanceof HTMLInputElement ||
148-
ae instanceof HTMLTextAreaElement ||
149-
ae?.hasAttribute('contenteditable')
150-
if (isEditable) continue
151-
}
156+
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
152157

153158
if (matchesShortcut(e, cmd.parsed)) {
154159
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue

apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type CommandId =
1717
| 'clear-terminal-console'
1818
| 'focus-toolbar-search'
1919
| 'fit-to-view'
20+
| 'toggle-sidebar'
2021

2122
/**
2223
* Static metadata for a global command.
@@ -92,6 +93,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
9293
shortcut: 'Mod+Shift+F',
9394
allowInEditable: false,
9495
},
96+
'toggle-sidebar': {
97+
id: 'toggle-sidebar',
98+
shortcut: 'Mod+B',
99+
allowInEditable: false,
100+
},
95101
}
96102

97103
/**

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,20 @@ export function SidebarTooltip({
120120
label,
121121
enabled,
122122
side = 'right',
123+
shortcut,
123124
}: {
124125
children: React.ReactElement
125126
label: string
126127
enabled: boolean
127128
side?: 'right' | 'bottom'
129+
shortcut?: string
128130
}) {
129131
if (!enabled) return children
130132
return (
131133
<Tooltip.Root>
132134
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
133135
<Tooltip.Content side={side}>
134-
<p>{label}</p>
136+
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : <p>{label}</p>}
135137
</Tooltip.Content>
136138
</Tooltip.Root>
137139
)
@@ -1234,6 +1236,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
12341236
handleCreateWorkflow()
12351237
},
12361238
},
1239+
{
1240+
id: 'toggle-sidebar',
1241+
handler: () => {
1242+
toggleCollapsed()
1243+
},
1244+
},
12371245
])
12381246
)
12391247

@@ -1284,7 +1292,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
12841292
isCollapsed={isCollapsed}
12851293
onExpandSidebar={toggleCollapsed}
12861294
/>
1287-
<SidebarTooltip label='Collapse sidebar' enabled={!isCollapsed} side='bottom'>
1295+
<SidebarTooltip
1296+
label='Collapse sidebar'
1297+
enabled={!isCollapsed}
1298+
side='bottom'
1299+
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
1300+
>
12881301
<button
12891302
type='button'
12901303
onClick={toggleCollapsed}

0 commit comments

Comments
 (0)