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

@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { documentTitleForRoute } from './app/pageTitle'
import AppShell from './components/shell/AppShell.vue'
import { remountRevision } from './state/useRemount'
const route = useRoute()
const inShell = computed(() => route.meta.shell === true)
@@ -14,10 +15,12 @@ watchEffect(() => {
<template>
<!-- Keyed by path, not full path: a new controller or record remounts its
view, while list query changes (search, sort, page) keep it. -->
view, while list query changes (search, sort, page) keep it. The
remount revision is part of the key, so a view can remount itself in
place (Create and new). -->
<AppShell v-if="inShell">
<RouterView v-slot="{ Component, route: current }">
<component :is="Component" :key="current.path" />
<component :is="Component" :key="`${current.path}#${remountRevision}`" />
</RouterView>
</AppShell>
<RouterView v-else />

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)
}
}
}

View File

@@ -13,19 +13,27 @@ import { activeEntry } from '../../state/useNavigation'
import { settingsEntry, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import { t } from '../../app/i18n'
import { closeShortcutHelp, installHotkeys } from '../../app/hotkeys'
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
// the plugin rail, the section panel (the active plugin's side menu or, on a
// settings page, the settings panel of its category; hidden when the
// sidebar is collapsed by the viewport or by the admin), and the 64px header
// with breadcrumbs and the user menu. The toast queue is mounted here so
// toasts survive navigation.
// toasts survive navigation. The keyboard shortcuts (app/hotkeys.ts) are
// installed here, so only signed-in shell screens answer them.
const route = useRoute()
const { collapsed, watchViewport } = useSidebar()
const stopViewport = watchViewport()
onBeforeUnmount(stopViewport)
const uninstallHotkeys = installHotkeys()
onBeforeUnmount(() => {
uninstallHotkeys()
closeShortcutHelp()
})
// "View site" (WinterCMS parity) opens the public site in a new tab: the
// admin page's own origin, without the admin path prefix.
const siteURL = `${window.location.origin}/`

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { RouterLink, type RouteLocationRaw } from 'vue-router'
import { ariaKeyshortcuts } from '../../app/hotkeys'
// One button for the whole admin (design tokens): primary, outline, ghost and
// danger (outline). With `to` it renders a router link styled the same way.
// A hotkey prop marks the button for the admin's keyboard shortcuts
// (data-hotkey tokens, see app/hotkeys.ts).
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
@@ -12,8 +15,9 @@ const props = withDefaults(
disabled?: boolean
icon?: Component
to?: RouteLocationRaw
hotkey?: string
}>(),
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined, hotkey: undefined },
)
const variants: Record<NonNullable<typeof props.variant>, string> = {
@@ -23,6 +27,8 @@ const variants: Record<NonNullable<typeof props.variant>, string> = {
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
}
const keyshortcuts = computed(() => (props.hotkey ? ariaKeyshortcuts(props.hotkey) : undefined))
const classes = computed(() => [
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
props.size === 'sm' ? 'h-[38px]' : 'h-button',
@@ -31,11 +37,24 @@ const classes = computed(() => [
</script>
<template>
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
<RouterLink
v-if="to !== undefined"
:to="to"
:class="classes"
:data-hotkey="hotkey"
:aria-keyshortcuts="keyshortcuts"
>
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</RouterLink>
<button v-else :type="type" :disabled="disabled" :class="classes">
<button
v-else
:type="type"
:disabled="disabled"
:class="classes"
:data-hotkey="hotkey"
:aria-keyshortcuts="keyshortcuts"
>
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</button>

View File

@@ -0,0 +1,15 @@
// Lets a view ask App to remount the current route's component in place:
// App keys the routed view by path plus this revision, so bumping it gives
// the view a fresh instance without a navigation (Create and new on a
// create form opens a blank form at the same path).
import { readonly, ref } from 'vue'
const revision = ref(0)
/** Part of App's routed view key; bumped by remountView. */
export const remountRevision = readonly(revision)
/** Remounts the current route's view. */
export function remountView(): void {
revision.value++
}

View File

@@ -6,6 +6,7 @@ import { api } from '../api/client'
import { parentFileRoutes } from '../api/files'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta, RelationOption } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { shortcutHint } from '../app/hotkeys'
import { currentLocale, message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { SESSION_HEADER, newSessionKey } from '../app/sessionKey'
@@ -58,12 +59,15 @@ import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { remountView } from '../state/useRemount'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
// 4). The view is keyed by route path, so one instance serves one record or
// one create. Tabs group fields by `tab`; a sticky footer holds delete,
// cancel, save-and-close and save; a dirty form confirms before leaving.
// cancel, save-and-close and save (a create form also has create-and-new);
// a dirty form confirms before leaving. The save buttons carry the keyboard
// shortcuts (Ctrl/Cmd+S, Ctrl/Cmd+Enter, Ctrl/Cmd+Shift+S; app/hotkeys.ts).
const route = useRoute()
const router = useRouter()
@@ -527,6 +531,18 @@ async function onSaveAndClose(): Promise<void> {
}
}
/**
* Utwórz i nowy (WinterCMS create_and_new): saves, then shows a blank create
* form of the same model in place, with a new session key; both redirects
* are ignored.
*/
async function onSaveAndNew(): Promise<void> {
const record = await save()
if (record) {
remountView()
}
}
async function onDelete(): Promise<void> {
if (recordId === null || busy.value || !schema.value) {
return
@@ -676,10 +692,35 @@ void load()
</Button>
<div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" @click="onLeave">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
<Button
v-if="mode === 'create'"
variant="outline"
data-action="save-new"
hotkey="mod+shift+s"
:title="shortcutHint(t('backend::lang.form.create_and_new'), 'mod+shift+s')"
:disabled="busy || loading || !schema || activeUploads > 0"
@click="onSaveAndNew"
>
{{ t('backend::lang.form.create_and_new') }}
</Button>
<Button
variant="outline"
data-action="save-close"
hotkey="mod+enter"
:title="shortcutHint(t('backend::lang.form.save_and_close'), 'mod+enter')"
:disabled="busy || loading || !schema || activeUploads > 0"
@click="onSaveAndClose"
>
{{ t('backend::lang.form.save_and_close') }}
</Button>
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSave">
<Button
variant="primary"
data-action="save"
hotkey="mod+s"
:title="shortcutHint(t('backend::lang.form.save'), 'mod+s')"
:disabled="busy || loading || !schema || activeUploads > 0"
@click="onSave"
>
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</div>