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

View File

@@ -246,6 +246,9 @@
"backend::lang.form.create": {
"other": "Utwórz"
},
"backend::lang.form.create_and_new": {
"other": "Utwórz i nowy"
},
"backend::lang.form.delete": {
"other": "Usuń"
},
@@ -845,6 +848,24 @@
},
"backend::lang.settings.title": {
"other": "Ustawienia"
},
"backend::lang.shortcuts.description": {
"other": "Skróty dostępne na tym ekranie."
},
"backend::lang.shortcuts.help": {
"other": "Pokaż skróty klawiaturowe"
},
"backend::lang.shortcuts.hint": {
"other": ":label (:keys)"
},
"backend::lang.shortcuts.leave": {
"other": "Anuluj i wróć"
},
"backend::lang.shortcuts.search": {
"other": "Przejdź do wyszukiwania"
},
"backend::lang.shortcuts.title": {
"other": "Skróty klawiaturowe"
}
},
"meta": {

View File

@@ -0,0 +1,68 @@
// Keyboard shortcuts on the whole app (mounted with attachTo, since the
// shortcut listener reads the document): the save keys on record forms.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import { API, mountApp, requestsTo, type Route } from '../helpers'
const RECORD = `${API}/acme/demo/widgets/1`
const formRoutes: Record<string, Route> = {
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
[`GET ${RECORD}`]: { body: record },
[`PUT ${RECORD}`]: { body: record },
}
beforeEach(() => {
clearUser()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
})
afterEach(() => {
document.body.innerHTML = ''
})
// Registered after the cleanup above, so it runs first (hooks run as a
// stack): the app unmounts, with its listeners and portals, before the body
// is cleared.
enableAutoUnmount(afterEach)
function press(target: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
target.dispatchEvent(event)
return event
}
describe('record form save shortcuts', () => {
it('saves an update form on Ctrl+S and Cmd+S without the browser Save page', async () => {
const { calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
const ctrl = press(document.body, 's', { ctrlKey: true })
expect(ctrl.defaultPrevented).toBe(true)
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
const cmd = press(document.body, 's', { metaKey: true })
expect(cmd.defaultPrevented).toBe(true)
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
})
it('shows the shortcut in the save button tooltip', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
const save = wrapper.get('[data-action="save"]')
expect(save.attributes('title')).toBe('Zapisz (Ctrl+S)')
expect(save.attributes('aria-keyshortcuts')).toBe('Control+S Meta+S')
expect(wrapper.get('[data-action="save-close"]').attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
})
})