- hotkey core: combos, AltGr and layouts, labels, aria-keyshortcuts, editable targets, modal root, refcounted install, disabled and repeat - smoke: record, create, Create and new (new session key), Esc, modal scope, settings Save and close, My account, list search and the help dialog - ShortcutHelpDialog rows, keys and close button
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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<string, string>): 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 = `
|
|
<input id="i"><textarea id="t"></textarea><select id="s"></select>
|
|
<div contenteditable="true"><span id="c">x</span></div>
|
|
<div contenteditable="false"><span id="n">x</span></div>
|
|
<button id="b"></button>`
|
|
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()
|
|
})
|
|
})
|