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

@@ -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>