import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ariaKeyshortcuts, availableShortcuts, closeShortcutHelp, comboOf, hotkeyRoot, installHotkeys, isEditableTarget, isMacPlatform, shortcutHelp, shortcutHint, shortcutLabel, shortcutParts, } from '../../src/app/hotkeys' import { setBundle } from '../../src/app/i18n' import lang from '../fixtures/lang.json' import { press } from '../helpers' const key = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init) let uninstalls: Array<() => void> = [] function install(): () => void { const uninstall = installHotkeys() uninstalls.push(uninstall) return uninstall } function button(hotkey: string, parent: ParentNode = document.body): HTMLButtonElement { const element = document.createElement('button') element.setAttribute('data-hotkey', hotkey) parent.appendChild(element) return element } function dialog(attrs: Record): HTMLDivElement { const element = document.createElement('div') for (const [name, value] of Object.entries(attrs)) { element.setAttribute(name, value) } document.body.appendChild(element) return element } beforeEach(() => { setBundle(lang.data, 'pl') }) afterEach(() => { for (const uninstall of uninstalls) { uninstall() } uninstalls = [] closeShortcutHelp() document.body.innerHTML = '' }) describe('comboOf', () => { it('maps the modifier combos with Ctrl or Cmd', () => { expect(comboOf(key({ key: 's', ctrlKey: true }))).toBe('mod+s') expect(comboOf(key({ key: 's', metaKey: true }))).toBe('mod+s') expect(comboOf(key({ key: 'S', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+s') expect(comboOf(key({ key: 'Enter', ctrlKey: true }))).toBe('mod+enter') expect(comboOf(key({ key: 'Enter', metaKey: true }))).toBe('mod+enter') }) it('maps the plain keys whatever the shift state', () => { expect(comboOf(key({ key: 'Escape' }))).toBe('escape') expect(comboOf(key({ key: '/' }))).toBe('/') expect(comboOf(key({ key: '?', shiftKey: true }))).toBe('?') }) it('ignores AltGr, Alt, plain letters, Ctrl+Shift+Enter and composition', () => { expect(comboOf(key({ key: 's', ctrlKey: true, altKey: true }))).toBeNull() expect(comboOf(key({ key: 's', altKey: true }))).toBeNull() expect(comboOf(key({ key: 's' }))).toBeNull() expect(comboOf(key({ key: 'Enter', ctrlKey: true, shiftKey: true }))).toBeNull() expect(comboOf(key({ key: 's', ctrlKey: true, isComposing: true }))).toBeNull() }) it('reads the physical key of a non-Latin layout', () => { expect(comboOf(key({ key: 'ы', code: 'KeyS', ctrlKey: true }))).toBe('mod+s') expect(comboOf(key({ key: 'ы', code: 'KeyD', ctrlKey: true }))).toBeNull() }) }) describe('labels', () => { it('detects Apple platforms', () => { expect(isMacPlatform('MacIntel')).toBe(true) expect(isMacPlatform('iPhone')).toBe(true) expect(isMacPlatform('iPad')).toBe(true) expect(isMacPlatform('Linux x86_64')).toBe(false) expect(isMacPlatform('Win32')).toBe(false) }) it('reads navigator.platform by default', () => { vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel') expect(isMacPlatform()).toBe(true) expect(shortcutLabel('mod+s')).toBe('⌘S') }) it('names the keys with words on Linux and glyphs on macOS', () => { expect(shortcutLabel('mod+s', false)).toBe('Ctrl+S') expect(shortcutLabel('mod+enter', false)).toBe('Ctrl+Enter') expect(shortcutLabel('mod+shift+s', false)).toBe('Ctrl+Shift+S') expect(shortcutLabel('escape', false)).toBe('Esc') expect(shortcutLabel('mod+s', true)).toBe('⌘S') expect(shortcutLabel('mod+enter', true)).toBe('⌘↩') expect(shortcutLabel('mod+shift+s', true)).toBe('⇧⌘S') expect(shortcutParts('mod+shift+s', true)).toEqual(['⇧', '⌘', 'S']) expect(shortcutParts('/', false)).toEqual(['/']) expect(shortcutParts('?', true)).toEqual(['?']) }) it('builds the tooltip hint from the phrasebook string', () => { expect(shortcutHint('Zapisz', 'mod+s', false)).toBe('Zapisz (Ctrl+S)') expect(shortcutHint('Zapisz', 'mod+s', true)).toBe('Zapisz (⌘S)') }) it('lists the Control and Meta forms for aria-keyshortcuts', () => { expect(ariaKeyshortcuts('mod+s mod+enter')).toBe('Control+S Meta+S Control+Enter Meta+Enter') expect(ariaKeyshortcuts('mod+shift+s')).toBe('Control+Shift+S Meta+Shift+S') expect(ariaKeyshortcuts('escape')).toBe('Escape') expect(ariaKeyshortcuts('/')).toBe('/') }) }) describe('isEditableTarget', () => { it('is true inside inputs, textareas, selects and contenteditable', () => { document.body.innerHTML = `
x
x
` for (const id of ['i', 't', 's', 'c']) { expect(isEditableTarget(document.getElementById(id))).toBe(true) } expect(isEditableTarget(document.getElementById('b'))).toBe(false) expect(isEditableTarget(document.getElementById('n'))).toBe(false) expect(isEditableTarget(document.body)).toBe(false) expect(isEditableTarget(null)).toBe(false) }) }) describe('hotkeyRoot', () => { it('is the document without a modal', () => { expect(hotkeyRoot(document)).toBe(document) }) it('is the last open aria-modal dialog or alert dialog', () => { dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' }) const second = dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' }) expect(hotkeyRoot(document)).toBe(second) const alert = dialog({ role: 'alertdialog', 'data-state': 'open' }) expect(hotkeyRoot(document)).toBe(alert) }) it('ignores popovers (no aria-modal) and closing dialogs', () => { const modal = dialog({ role: 'dialog', 'aria-modal': 'true' }) dialog({ role: 'dialog' }) dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'closed' }) expect(hotkeyRoot(document)).toBe(modal) }) }) describe('availableShortcuts', () => { it('lists the targets of the current root plus ?', () => { button('mod+s') button('escape') expect(availableShortcuts().map((entry) => entry.combo)).toEqual(['mod+s', 'escape', '?']) const modal = dialog({ role: 'dialog', 'aria-modal': 'true' }) button('mod+s mod+enter', modal) expect(availableShortcuts().map((entry) => entry.combo)).toEqual(['mod+s', 'mod+enter', '?']) }) }) describe('installHotkeys', () => { it('clicks the target once per keydown however often it is installed, and stops after every uninstall', () => { const save = button('mod+s') const click = vi.spyOn(save, 'click') const first = install() const second = install() press(document.body, 's', { ctrlKey: true }) expect(click).toHaveBeenCalledTimes(1) first() first() press(document.body, 's', { ctrlKey: true }) expect(click).toHaveBeenCalledTimes(2) second() const event = press(document.body, 's', { ctrlKey: true }) expect(click).toHaveBeenCalledTimes(2) expect(event.defaultPrevented).toBe(false) }) it('consumes the key without clicking a disabled target or on auto-repeat', () => { install() const save = button('mod+s') const click = vi.spyOn(save, 'click') expect(press(document.body, 's', { ctrlKey: true, repeat: true }).defaultPrevented).toBe(true) expect(click).not.toHaveBeenCalled() save.disabled = true expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true) expect(click).not.toHaveBeenCalled() save.disabled = false save.setAttribute('aria-disabled', 'true') press(document.body, 's', { ctrlKey: true }) expect(click).not.toHaveBeenCalled() }) it('prevents the browser Save page without a target, and lets other modifier keys through', () => { install() expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true) expect(press(document.body, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(false) expect(press(document.body, 'S', { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false) }) it('stops a handled modifier key before an editor sees it, also inside an editable control', () => { install() const save = button('mod+enter') const click = vi.spyOn(save, 'click') const editor = document.createElement('div') editor.setAttribute('contenteditable', 'true') document.body.appendChild(editor) const editorKeys = vi.fn() editor.addEventListener('keydown', editorKeys) const event = press(editor, 'Enter', { ctrlKey: true }) expect(event.defaultPrevented).toBe(true) expect(click).toHaveBeenCalledTimes(1) expect(editorKeys).not.toHaveBeenCalled() }) it('searches only the innermost modal', () => { install() const page = button('mod+s') const pageClick = vi.spyOn(page, 'click') const modal = dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' }) const submit = button('mod+s mod+enter', modal) const submitClick = vi.spyOn(submit, 'click') press(document.body, 's', { ctrlKey: true }) press(document.body, 'Enter', { metaKey: true }) expect(submitClick).toHaveBeenCalledTimes(2) expect(pageClick).not.toHaveBeenCalled() dialog({ role: 'alertdialog', 'data-state': 'open' }) expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true) expect(submitClick).toHaveBeenCalledTimes(2) expect(pageClick).not.toHaveBeenCalled() }) it('runs plain keys outside editable controls and open layers only', () => { install() const cancel = button('escape') const click = vi.spyOn(cancel, 'click') const input = document.createElement('input') document.body.appendChild(input) expect(press(input, 'Escape').defaultPrevented).toBe(false) expect(click).not.toHaveBeenCalled() expect(press(document.body, 'Escape').defaultPrevented).toBe(true) expect(click).toHaveBeenCalledTimes(1) const handled = document.createElement('div') document.body.appendChild(handled) handled.addEventListener('keydown', (event) => event.preventDefault()) press(handled, 'Escape') expect(click).toHaveBeenCalledTimes(1) const layer = dialog({ 'data-dismissable-layer': '' }) press(document.body, 'Escape') expect(click).toHaveBeenCalledTimes(1) layer.remove() cancel.disabled = true expect(press(document.body, 'Escape').defaultPrevented).toBe(false) }) it('focuses an input target and opens the help on ?', () => { install() const search = document.createElement('input') search.setAttribute('data-hotkey', '/') search.value = 'abc' document.body.appendChild(search) button('mod+s') expect(press(document.body, '/').defaultPrevented).toBe(true) expect(document.activeElement).toBe(search) expect(press(search, '?', { shiftKey: true }).defaultPrevented).toBe(false) expect(shortcutHelp.value).toBeNull() search.blur() expect(press(document.body, '?', { shiftKey: true }).defaultPrevented).toBe(true) expect(shortcutHelp.value?.map((entry) => entry.combo)).toEqual(['mod+s', '/', '?']) closeShortcutHelp() expect(shortcutHelp.value).toBeNull() }) })