diff --git a/frontend/src/lib/components/Shortcuts/shortcutLogic.test.ts b/frontend/src/lib/components/Shortcuts/shortcutLogic.test.ts new file mode 100644 index 000000000000..ccccdcf0cddd --- /dev/null +++ b/frontend/src/lib/components/Shortcuts/shortcutLogic.test.ts @@ -0,0 +1,56 @@ +import { initKeaTests } from '~/test/init' + +import { shortcutLogic } from './shortcutLogic' + +// The ⌘⌥ matching path branches on isMac(), captured once at module load. Force it so the +// modifier resolves to 'command' regardless of the host platform running the test. +jest.mock('lib/utils/dom', () => ({ + ...jest.requireActual('lib/utils/dom'), + isMac: () => true, +})) + +describe('shortcutLogic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests(false) + logic = shortcutLogic() + logic.mount() + }) + + afterEach(() => { + logic.unmount() + }) + + function register(name: string, keybind: string[][]): jest.Mock { + const callback = jest.fn() + logic.actions.registerShortcut({ name, keybind, intent: name, interaction: 'function', callback }) + return callback + } + + function press(init: KeyboardEventInit): void { + window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init })) + } + + it('matches ⌘⌥ shortcuts by the layout-aware key, not the physical key position', () => { + const onC = register('c-action', [['command', 'option', 'c']]) + const onI = register('i-action', [['command', 'option', 'i']]) + + // On Dvorak the key labelled "c" sits where QWERTY has "i": event.code is "KeyI" + // but event.key is the true letter "c". Matching must follow the letter, not the position. + press({ key: 'c', code: 'KeyI', metaKey: true, altKey: true }) + + expect(onC).toHaveBeenCalledTimes(1) + expect(onI).not.toHaveBeenCalled() + }) + + it('falls back to the physical key only when Option turns event.key into a non-letter glyph', () => { + const onK = register('k-action', [['command', 'option', 'k']]) + + // macOS US layout: ⌥K produces the glyph "˚" as event.key, so the physical code is the + // only reliable source of the intended letter. + press({ key: '˚', code: 'KeyK', metaKey: true, altKey: true }) + + expect(onK).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/components/Shortcuts/shortcutLogic.tsx b/frontend/src/lib/components/Shortcuts/shortcutLogic.tsx index a6c40ce162fd..353ff1aa99bb 100644 --- a/frontend/src/lib/components/Shortcuts/shortcutLogic.tsx +++ b/frontend/src/lib/components/Shortcuts/shortcutLogic.tsx @@ -187,9 +187,14 @@ export const shortcutLogic = kea([ pressedKeys.push('option') } - // Handle Alt key combinations - event.key can change with Alt held + // event.key is layout-aware, so it stays correct on non-QWERTY layouts (Dvorak, + // AZERTY, etc.) where the physical event.code position does not match the letter. + // Prefer it. The only reason to consult the physical event.code is the macOS quirk + // where holding Option turns event.key into a special glyph or dead key (e.g. ⌥K + // becomes "˚"); fall back to event.code only in that case, never when event.key + // already gives us a usable letter or digit. let keyToAdd = event.key.toLowerCase() - if (event.altKey) { + if (event.altKey && !/^[a-z0-9]$/.test(keyToAdd)) { const codeMatch = event.code.match(/^Key([A-Z])$/) if (codeMatch) { keyToAdd = codeMatch[1].toLowerCase()