Skip to content

Commit e5a7de0

Browse files
committed
fix(hub-ui): preserve keystrokes while recording shortcuts
1 parent 18fa60e commit e5a7de0

2 files changed

Lines changed: 73 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { DevframeDocksUserSettings } from '@devframes/hub'
2+
import type { DevframeRpcClient } from '@devframes/hub/client'
3+
import { createSharedState } from 'devframe/utils/shared-state'
4+
import { afterEach, expect, it, vi } from 'vitest'
5+
import { effectScope, nextTick, shallowRef } from 'vue'
6+
import { createCommandsContext } from './commands'
7+
import { isMac } from './keybindings'
8+
import { useDockPopupWindow } from './popup'
9+
10+
vi.mock('./popup', () => ({
11+
useDockPopupWindow: vi.fn(() => shallowRef(null)),
12+
useIsDockPopupOpen: () => shallowRef(false),
13+
}))
14+
15+
afterEach(() => vi.unstubAllGlobals())
16+
17+
it.each(['standalone', 'shadow-root', 'popup'] as const)('leaves recording keystrokes untouched in %s and resumes shortcuts outside the recorder', async (mode) => {
18+
const listeners = new Map<string, (event: KeyboardEvent) => void>()
19+
const host = { addEventListener: vi.fn((name, handler) => listeners.set(name, handler)) }
20+
vi.stubGlobal('window', host)
21+
const popup = shallowRef<Window | null>(null)
22+
vi.mocked(useDockPopupWindow).mockReturnValue(popup)
23+
const scope = effectScope()
24+
try {
25+
// eslint-disable-next-line slop/no-chained-type-assertions -- only sharedState is used by this command context fixture.
26+
const rpc = {
27+
sharedState: { get: async () => createSharedState({ initialValue: [] }) },
28+
} as unknown as DevframeRpcClient
29+
const settings = createSharedState<DevframeDocksUserSettings>({
30+
initialValue: { docksHidden: [], docksCategoriesHidden: [], docksPinned: [], docksCustomOrder: {}, commandShortcuts: {} },
31+
})
32+
const context = await scope.run(() => createCommandsContext('standalone', rpc, settings))!
33+
const action = vi.fn()
34+
context.register({ id: 'test:palette', source: 'client', title: 'Palette', keybindings: [{ key: 'Mod+K' }], action })
35+
if (mode === 'popup') {
36+
// eslint-disable-next-line slop/no-chained-type-assertions -- the popup fixture only needs the listener registration surface.
37+
popup.value = host as unknown as Window
38+
await nextTick()
39+
}
40+
const recorder = Object.assign(new EventTarget(), { classList: { contains: (name: string) => name === 'shortcut-key-input' } })
41+
const outside = Object.assign(new EventTarget(), { classList: { contains: () => false } })
42+
// eslint-disable-next-line slop/no-chained-type-assertions -- Node has no KeyboardEvent; this fixture supplies the fields the shortcut listener reads.
43+
const event = Object.assign(new Event('keydown'), {
44+
key: 'k',
45+
metaKey: isMac,
46+
ctrlKey: !isMac,
47+
composedPath: () => [recorder, outside],
48+
preventDefault: vi.fn(),
49+
stopPropagation: vi.fn(),
50+
}) as unknown as KeyboardEvent
51+
Object.defineProperty(event, 'target', { value: mode === 'shadow-root' ? outside : recorder })
52+
const handler = listeners.get('keydown')!
53+
handler(event)
54+
expect(action).not.toHaveBeenCalled()
55+
expect(event.preventDefault).not.toHaveBeenCalled()
56+
expect(event.stopPropagation).not.toHaveBeenCalled()
57+
58+
event.composedPath = () => [outside]
59+
handler(event)
60+
expect(action).toHaveBeenCalledOnce()
61+
expect(event.preventDefault).toHaveBeenCalledOnce()
62+
expect(event.stopPropagation).toHaveBeenCalledOnce()
63+
}
64+
finally {
65+
scope.stop()
66+
}
67+
})

‎packages/hub-ui/src/client/state/commands.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ function setupShortcutListener(
144144
execute: (id: string, ...args: any[]) => Promise<unknown>,
145145
) {
146146
const handler = (e: KeyboardEvent) => {
147+
// Let the shortcut editor record keys without executing their commands.
148+
// composedPath reaches the input through shadow DOM; structural checks also
149+
// work for elements belonging to the popup window's realm.
150+
if (e.composedPath().some(target => (target as Element).classList?.contains('shortcut-key-input')))
151+
return
152+
147153
const pressed = normalizeKeyEvent(e)
148154
if (!pressed || pressed === 'Mod' || pressed === 'Shift' || pressed === 'Alt')
149155
return

0 commit comments

Comments
 (0)