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:
@@ -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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}/`
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
admin/src/state/useRemount.ts
Normal file
15
admin/src/state/useRemount.ts
Normal 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++
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user