feat(quick-261022-k9k): admin save shortcuts and Create and new on record forms
- app/hotkeys.ts: dependency-free data-hotkey core (Ctrl/Cmd+S, Ctrl/Cmd+Enter, Ctrl/Cmd+Shift+S, Esc, / and ?), innermost-modal root, capture-phase modifier listener, platform labels and the help-dialog state - Button hotkey prop (data-hotkey, aria-keyshortcuts); shortcuts installed by AppShell - FormView: save keys and tooltips, Create and new on create forms via remountView - phrasebook: form.create_and_new and the shortcuts group (en, pl)
This commit is contained in:
263
admin/src/app/hotkeys.ts
Normal file
263
admin/src/app/hotkeys.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Admin keyboard shortcuts, after WinterCMS's data-hotkey: a button carries
|
||||
// the shortcut in a data-hotkey attribute (space-separated tokens) and the
|
||||
// shortcut clicks that button, so it does exactly what the button does and
|
||||
// nothing while the button is disabled. Supported tokens: mod+s, mod+enter,
|
||||
// mod+shift+s (mod is Ctrl or Cmd, both accepted on every platform, as
|
||||
// WinterCMS binds ctrl+s and cmd+s), escape, / and ?. An input carrying
|
||||
// a token is focused instead of clicked (the list search).
|
||||
//
|
||||
// Where a shortcut looks: the innermost open modal (a dialog with
|
||||
// aria-modal, or an alert dialog), else the whole page, so a modal form
|
||||
// takes the save keys from the form underneath and a confirmation takes
|
||||
// them from both (hotkeyRoot). Popovers are dialogs without aria-modal and
|
||||
// are not roots.
|
||||
//
|
||||
// Modifier shortcuts fire everywhere, also while typing in an input, a
|
||||
// textarea, a code field or the rich editor. Their listener runs in the
|
||||
// window capture phase and stops the event when a target exists, so the
|
||||
// rich editor does not also insert a hard break (Mod-Enter) or toggle
|
||||
// strikethrough (Mod-Shift-s); without a target the event passes through and
|
||||
// those editor keys keep working. Ctrl+S never opens the browser's Save page
|
||||
// inside the shell. Plain keys (Esc, / and ?) never fire in an editable
|
||||
// control, after a component handled the key (defaultPrevented), or while any
|
||||
// dismissable layer (dialog, popover, dropdown, select, link popover) is
|
||||
// open: those close on Esc themselves without calling preventDefault, so the
|
||||
// DOM is checked for their data-dismissable-layer attribute instead.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { t } from './i18n'
|
||||
|
||||
export type HotkeyCombo = 'mod+s' | 'mod+enter' | 'mod+shift+s' | 'escape' | '/' | '?'
|
||||
|
||||
export interface ShortcutEntry {
|
||||
combo: HotkeyCombo
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
/** Every shortcut the admin knows, in help-dialog order. */
|
||||
export const SHORTCUTS: readonly ShortcutEntry[] = [
|
||||
{ combo: 'mod+s', labelKey: 'backend::lang.form.save' },
|
||||
{ combo: 'mod+enter', labelKey: 'backend::lang.form.save_and_close' },
|
||||
{ combo: 'mod+shift+s', labelKey: 'backend::lang.form.create_and_new' },
|
||||
{ combo: 'escape', labelKey: 'backend::lang.shortcuts.leave' },
|
||||
{ combo: '/', labelKey: 'backend::lang.shortcuts.search' },
|
||||
{ combo: '?', labelKey: 'backend::lang.shortcuts.help' },
|
||||
]
|
||||
|
||||
const MOD_COMBOS: ReadonlySet<HotkeyCombo> = new Set(['mod+s', 'mod+enter', 'mod+shift+s'])
|
||||
|
||||
/** True on macOS and iOS (the labels use the ⌘ glyphs there). */
|
||||
export function isMacPlatform(platform?: string): boolean {
|
||||
const value = platform ?? (typeof navigator === 'undefined' ? '' : navigator.platform || '')
|
||||
return /mac|iphone|ipad|ipod/i.test(value)
|
||||
}
|
||||
|
||||
/** The letter of a modified key; event.code covers non-Latin layouts. */
|
||||
function letterOf(event: KeyboardEvent): string {
|
||||
if (/^[a-z]$/i.test(event.key)) {
|
||||
return event.key.toLowerCase()
|
||||
}
|
||||
const match = /^Key([A-Z])$/.exec(event.code ?? '')
|
||||
return match ? match[1]!.toLowerCase() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortcut a keydown means, or null. Modifier combos need Ctrl or Cmd
|
||||
* and no Alt (AltGr arrives as Ctrl+Alt and types letters such as ś).
|
||||
*/
|
||||
export function comboOf(event: KeyboardEvent): HotkeyCombo | null {
|
||||
if (event.isComposing) {
|
||||
return null
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.altKey) {
|
||||
return null
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
return event.shiftKey ? null : 'mod+enter'
|
||||
}
|
||||
if (letterOf(event) === 's') {
|
||||
return event.shiftKey ? 'mod+shift+s' : 'mod+s'
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (event.altKey) {
|
||||
return null
|
||||
}
|
||||
// Shift is ignored for / and ?: keyboard layouts place them differently.
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
return 'escape'
|
||||
case '/':
|
||||
return '/'
|
||||
case '?':
|
||||
return '?'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the target is inside an input, textarea, select or contenteditable element. */
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as Element).closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return (target as Element).closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null
|
||||
}
|
||||
|
||||
/** The keys of a shortcut as shown on screen, one chip each. */
|
||||
export function shortcutParts(combo: HotkeyCombo, mac: boolean = isMacPlatform()): string[] {
|
||||
switch (combo) {
|
||||
case 'mod+s':
|
||||
return mac ? ['⌘', 'S'] : ['Ctrl', 'S']
|
||||
case 'mod+enter':
|
||||
return mac ? ['⌘', '↩'] : ['Ctrl', 'Enter']
|
||||
case 'mod+shift+s':
|
||||
return mac ? ['⇧', '⌘', 'S'] : ['Ctrl', 'Shift', 'S']
|
||||
case 'escape':
|
||||
return ['Esc']
|
||||
default:
|
||||
return [combo]
|
||||
}
|
||||
}
|
||||
|
||||
/** The shortcut as one string: Ctrl+Shift+S, or ⇧⌘S on macOS. */
|
||||
export function shortcutLabel(combo: HotkeyCombo, mac: boolean = isMacPlatform()): string {
|
||||
return shortcutParts(combo, mac).join(mac ? '' : '+')
|
||||
}
|
||||
|
||||
/** A button tooltip naming its shortcut, for example "Zapisz (Ctrl+S)". */
|
||||
export function shortcutHint(label: string, combo: HotkeyCombo, mac: boolean = isMacPlatform()): string {
|
||||
return t('backend::lang.shortcuts.hint', { label, keys: shortcutLabel(combo, mac) })
|
||||
}
|
||||
|
||||
const ARIA: Record<HotkeyCombo, string> = {
|
||||
'mod+s': 'Control+S Meta+S',
|
||||
'mod+enter': 'Control+Enter Meta+Enter',
|
||||
'mod+shift+s': 'Control+Shift+S Meta+Shift+S',
|
||||
escape: 'Escape',
|
||||
'/': '/',
|
||||
'?': '?',
|
||||
}
|
||||
|
||||
/** The aria-keyshortcuts value of a data-hotkey token list (Control and Meta forms of each mod token). */
|
||||
export function ariaKeyshortcuts(hotkey: string): string {
|
||||
return hotkey
|
||||
.split(/\s+/)
|
||||
.filter((token): token is HotkeyCombo => token in ARIA)
|
||||
.map((token) => ARIA[token])
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** The innermost open modal (aria-modal dialog or alert dialog), else the document. */
|
||||
export function hotkeyRoot(doc: Document = document): ParentNode {
|
||||
const modals = Array.from(
|
||||
doc.querySelectorAll('[role="dialog"][aria-modal="true"], [role="alertdialog"]'),
|
||||
).filter((element) => element.getAttribute('data-state') !== 'closed')
|
||||
return modals.at(-1) ?? doc
|
||||
}
|
||||
|
||||
/** The first element in root whose data-hotkey lists the combo. */
|
||||
export function hotkeyTarget(combo: HotkeyCombo, root: ParentNode): HTMLElement | null {
|
||||
return root.querySelector<HTMLElement>(`[data-hotkey~="${combo}"]`)
|
||||
}
|
||||
|
||||
/** The shortcuts the current screen answers, plus ? itself. */
|
||||
export function availableShortcuts(doc: Document = document): ShortcutEntry[] {
|
||||
const root = hotkeyRoot(doc)
|
||||
return SHORTCUTS.filter((entry) => entry.combo === '?' || hotkeyTarget(entry.combo, root) !== null)
|
||||
}
|
||||
|
||||
const help = ref<readonly ShortcutEntry[] | null>(null)
|
||||
|
||||
/** The entries the help dialog shows, or null while it is closed. */
|
||||
export const shortcutHelp = readonly(help)
|
||||
|
||||
export function openShortcutHelp(): void {
|
||||
help.value = availableShortcuts()
|
||||
}
|
||||
|
||||
export function closeShortcutHelp(): void {
|
||||
help.value = null
|
||||
}
|
||||
|
||||
function isDisabled(element: HTMLElement): boolean {
|
||||
return (element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true'
|
||||
}
|
||||
|
||||
function onModifierKey(event: KeyboardEvent): void {
|
||||
const combo = comboOf(event)
|
||||
if (!combo || !MOD_COMBOS.has(combo)) {
|
||||
return
|
||||
}
|
||||
if (combo === 'mod+s') {
|
||||
event.preventDefault()
|
||||
}
|
||||
const doc = (event.target as Node | null)?.ownerDocument ?? document
|
||||
const target = hotkeyTarget(combo, hotkeyRoot(doc))
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.repeat || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
target.click()
|
||||
}
|
||||
|
||||
function onPlainKey(event: KeyboardEvent): void {
|
||||
const combo = comboOf(event)
|
||||
if (combo !== 'escape' && combo !== '/' && combo !== '?') {
|
||||
return
|
||||
}
|
||||
if (event.defaultPrevented || isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
const doc = (event.target as Node | null)?.ownerDocument ?? document
|
||||
if (doc.querySelector('[data-dismissable-layer]')) {
|
||||
return
|
||||
}
|
||||
if (combo === '?') {
|
||||
event.preventDefault()
|
||||
openShortcutHelp()
|
||||
return
|
||||
}
|
||||
const target = hotkeyTarget(combo, doc)
|
||||
if (!target || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
target.focus()
|
||||
target.select()
|
||||
} else {
|
||||
target.click()
|
||||
}
|
||||
}
|
||||
|
||||
let installs = 0
|
||||
|
||||
/**
|
||||
* Adds the window listeners; returns an idempotent uninstall. Reference
|
||||
* counted: the listeners exist once however many times it is installed and
|
||||
* go when every install has been undone.
|
||||
*/
|
||||
export function installHotkeys(): () => void {
|
||||
if (installs === 0) {
|
||||
window.addEventListener('keydown', onModifierKey, true)
|
||||
window.addEventListener('keydown', onPlainKey)
|
||||
}
|
||||
installs++
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
installs--
|
||||
if (installs === 0) {
|
||||
window.removeEventListener('keydown', onModifierKey, true)
|
||||
window.removeEventListener('keydown', onPlainKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user