test(quick-261022-k9k): keyboard shortcut coverage
- 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
This commit is contained in:
307
admin/tests/app/hotkeys.test.ts
Normal file
307
admin/tests/app/hotkeys.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user