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:
Jakub Zych
2026-10-22 21:00:57 +02:00
parent 1aff96f58a
commit 49accf675e
11 changed files with 465 additions and 9 deletions

263
admin/src/app/hotkeys.ts Normal file
View 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)
}
}
}