feat(quick-261022-k9k): Esc, slash and help shortcuts, settings, account and modal save keys
- '?' help dialog (ShortcutHelpDialog) listing the current screen's shortcuts - Esc = Cancel on record forms; '/' focuses the list search - settings forms: Save and close (Ctrl/Cmd+Enter, then /settings) and Ctrl/Cmd+S - My account: Ctrl/Cmd+S - modal forms: Ctrl/Cmd+S and Ctrl/Cmd+Enter submit the innermost dialog only - docs: Keyboard shortcuts section in the admin SPA page
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import { Check, CircleAlert, Copy, Info, LoaderCircle, TriangleAlert, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminAPITokenScope } from '../../api/types'
|
||||
import { shortcutHint } from '../../app/hotkeys'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controlClass } from '../form/control'
|
||||
import { fieldErrors, focusField } from '../form/formState'
|
||||
@@ -553,6 +554,8 @@ onBeforeUnmount(() => {
|
||||
type="submit"
|
||||
class="flex-1"
|
||||
data-action="create-token-submit"
|
||||
hotkey="mod+s mod+enter"
|
||||
:title="shortcutHint(t('backend::lang.api_tokens.create_submit'), 'mod+s')"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { CircleAlert, FileText, X } from '@lucide/vue'
|
||||
import { errorMessage, type FileRoutes } from '../../../api/files'
|
||||
import type { FileItem } from '../../../api/types'
|
||||
import { ariaKeyshortcuts, shortcutHint } from '../../../app/hotkeys'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { showToast } from '../../../state/useToasts'
|
||||
import ConfirmDialog from '../../ui/ConfirmDialog.vue'
|
||||
@@ -219,6 +220,9 @@ function onCloseAutoFocus(event: Event): void {
|
||||
<button
|
||||
type="submit"
|
||||
data-action="save-caption"
|
||||
data-hotkey="mod+s mod+enter"
|
||||
:aria-keyshortcuts="ariaKeyshortcuts('mod+s mod+enter')"
|
||||
:title="shortcutHint(t('backend::lang.fileupload.details_submit'), 'mod+s')"
|
||||
:disabled="busy"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import BulkActionsMenu from './BulkActionsMenu.vue'
|
||||
// Declared bulk actions (D-09) sit in one menu directly after the selection
|
||||
// pill; the menu is not rendered without a permitted action, and its trigger
|
||||
// is disabled while nothing is selected or a bulk action runs.
|
||||
// The slash key focuses the search input (app/hotkeys.ts).
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showSearch: boolean
|
||||
@@ -53,6 +54,8 @@ defineExpose({ focusBulk })
|
||||
<input
|
||||
type="search"
|
||||
data-list-search
|
||||
data-hotkey="/"
|
||||
aria-keyshortcuts="/"
|
||||
:value="search"
|
||||
:placeholder="searchPrompt"
|
||||
class="h-button w-full rounded-control border border-transparent bg-subtle pr-3.5 pl-10"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Trash2, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import { childFileRoutes } from '../../api/files'
|
||||
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationOption, RelationSchema } from '../../api/types'
|
||||
import { shortcutHint } from '../../app/hotkeys'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
|
||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||
@@ -549,6 +550,8 @@ function onCloseAutoFocus(event: Event): void {
|
||||
v-if="!failed"
|
||||
variant="primary"
|
||||
data-action="save-child"
|
||||
hotkey="mod+s mod+enter"
|
||||
:title="shortcutHint(submitLabel, 'mod+s')"
|
||||
:disabled="busy || loading || activeUploads > 0"
|
||||
@click="onSubmit"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Check, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
|
||||
import { ariaKeyshortcuts, shortcutHint } from '../../app/hotkeys'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { SESSION_HEADER } from '../../app/sessionKey'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
@@ -545,6 +546,9 @@ const pagerButton =
|
||||
v-else
|
||||
type="button"
|
||||
data-action="confirm-link"
|
||||
data-hotkey="mod+s mod+enter"
|
||||
:aria-keyshortcuts="ariaKeyshortcuts('mod+s mod+enter')"
|
||||
:title="shortcutHint(single ? message(schema.messages.linkSubmit) : t('backend::lang.relation.add_count', { count }), 'mod+s')"
|
||||
:disabled="busy || count === 0"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminRecord, ControllerParams, RelationSchema } from '../../api/types'
|
||||
import { ariaKeyshortcuts, shortcutHint } from '../../app/hotkeys'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { SESSION_HEADER } from '../../app/sessionKey'
|
||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||
@@ -266,6 +267,9 @@ function onCloseAutoFocus(event: Event): void {
|
||||
<button
|
||||
type="button"
|
||||
data-action="save-pivot"
|
||||
data-hotkey="mod+s mod+enter"
|
||||
:aria-keyshortcuts="ariaKeyshortcuts('mod+s mod+enter')"
|
||||
:title="shortcutHint(message(schema.messages.pivotSubmit), 'mod+s')"
|
||||
:disabled="busy || loading || failed"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="onSubmit"
|
||||
|
||||
@@ -7,6 +7,7 @@ import SettingsPanel from './SettingsPanel.vue'
|
||||
import Breadcrumbs from './Breadcrumbs.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import Toast from '../ui/Toast.vue'
|
||||
import ShortcutHelpDialog from './ShortcutHelpDialog.vue'
|
||||
import { ExternalLink } from '@lucide/vue'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { activeEntry } from '../../state/useNavigation'
|
||||
@@ -92,5 +93,6 @@ const shellOffset = computed(() =>
|
||||
</main>
|
||||
</div>
|
||||
<Toast />
|
||||
<ShortcutHelpDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
70
admin/src/components/shell/ShortcutHelpDialog.vue
Normal file
70
admin/src/components/shell/ShortcutHelpDialog.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { X } from '@lucide/vue'
|
||||
import { closeShortcutHelp, shortcutHelp, shortcutParts } from '../../app/hotkeys'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Keyboard shortcut help, opened with "?" (app/hotkeys.ts): the shortcuts
|
||||
// the current screen answers, read from the page when the key was pressed,
|
||||
// one row per shortcut with its keys and what it does. Esc and the close
|
||||
// button close it.
|
||||
function onOpen(open: boolean): void {
|
||||
if (!open) {
|
||||
closeShortcutHelp()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="shortcutHelp !== null" @update:open="onOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-shortcut-help
|
||||
data-dialog-content
|
||||
aria-modal="true"
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ t('backend::lang.shortcuts.title') }}</DialogTitle>
|
||||
<DialogDescription class="text-muted">{{ t('backend::lang.shortcuts.description') }}</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
data-action="close"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<dl class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-for="entry in shortcutHelp ?? []"
|
||||
:key="entry.combo"
|
||||
:data-shortcut="entry.combo"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<dt class="flex shrink-0 items-center gap-1">
|
||||
<kbd
|
||||
v-for="part in shortcutParts(entry.combo)"
|
||||
:key="part"
|
||||
class="inline-flex min-w-6 items-center justify-center rounded-pager border border-border bg-subtle px-1.5 font-mono text-[13px]"
|
||||
>{{ part }}</kbd>
|
||||
</dt>
|
||||
<dd class="text-right text-muted">{{ t(entry.labelKey) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminAccountRequest } from '../api/types'
|
||||
import { shortcutHint } from '../app/hotkeys'
|
||||
import { t } from '../app/i18n'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import { controlClass } from '../components/form/control'
|
||||
@@ -22,7 +23,7 @@ import { showToast } from '../state/useToasts'
|
||||
// typed. The avatar is uploaded (POST /account/avatar, one file_data part)
|
||||
// and removed (DELETE /account/avatar, after a confirmation) on its own.
|
||||
// Every success passes the returned profile to setCurrentUser, so the top
|
||||
// bar updates without a reload.
|
||||
// bar updates without a reload. Save answers Ctrl/Cmd+S (app/hotkeys.ts).
|
||||
const ID_PREFIX = 'account'
|
||||
|
||||
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
|
||||
@@ -427,7 +428,14 @@ async function onSave(): Promise<void> {
|
||||
<footer
|
||||
class="fixed right-0 bottom-0 left-[var(--shell-offset,0px)] z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
>
|
||||
<Button variant="primary" data-action="save" :disabled="busy" @click="onSave">
|
||||
<Button
|
||||
variant="primary"
|
||||
data-action="save"
|
||||
hotkey="mod+s"
|
||||
:title="shortcutHint(t('backend::lang.form.save'), 'mod+s')"
|
||||
:disabled="busy"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
@@ -691,7 +691,7 @@ void load()
|
||||
{{ t('backend::lang.form.delete') }}
|
||||
</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="ghost" data-action="cancel" hotkey="escape" @click="onLeave">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button
|
||||
v-if="mode === 'create'"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, FormField, FormView } from '../api/types'
|
||||
import { shortcutHint } from '../app/hotkeys'
|
||||
import { t } from '../app/i18n'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
@@ -34,7 +35,9 @@ import { showToast } from '../state/useToasts'
|
||||
// empty state from meta.secrets (F2) and are sent only when the
|
||||
// administrator typed a value (a string) or removed the stored one (null);
|
||||
// richeditor save notices from meta.notices render under their field (F3).
|
||||
// Save and Save and close answer Ctrl/Cmd+S and Ctrl/Cmd+Enter (app/hotkeys.ts).
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const code = String(route.params.code ?? '')
|
||||
const ID_PREFIX = 'setting'
|
||||
|
||||
@@ -132,9 +135,10 @@ function settingsPayload(editable: FormField[], current: AdminRecord): AdminReco
|
||||
return out
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
/** Saves the values; true when the PUT succeeded. */
|
||||
async function save(): Promise<boolean> {
|
||||
if (busy.value || !schema.value) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
@@ -148,7 +152,7 @@ async function onSave(): Promise<void> {
|
||||
secrets.value = { ...(result.data.data.meta?.secrets ?? {}) }
|
||||
notices.value = noticeLines(result.data.data.meta?.notices)
|
||||
showToast(t('backend::lang.settings.saved'))
|
||||
return
|
||||
return true
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
@@ -161,16 +165,29 @@ async function onSave(): Promise<void> {
|
||||
if (first) {
|
||||
focusField(ID_PREFIX, first.name)
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
||||
return false
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
return false
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
await save()
|
||||
}
|
||||
|
||||
/** Zapisz i zamknij: saves, then returns to the settings index (the Cancel target). */
|
||||
async function onSaveAndClose(): Promise<void> {
|
||||
if (await save()) {
|
||||
await router.push('/settings')
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
@@ -220,8 +237,27 @@ void load()
|
||||
</template>
|
||||
|
||||
<footer class="fixed right-0 bottom-0 left-[var(--shell-offset,0px)] z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5">
|
||||
<!-- No Esc on Cancel: a settings form has no unsaved-changes question,
|
||||
so Esc would discard edits silently. -->
|
||||
<Button variant="ghost" to="/settings">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave">
|
||||
<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"
|
||||
@click="onSaveAndClose"
|
||||
>
|
||||
{{ t('backend::lang.form.save_and_close') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-action="save"
|
||||
hotkey="mod+s"
|
||||
:title="shortcutHint(t('backend::lang.form.save'), 'mod+s')"
|
||||
:disabled="busy || loading || !schema"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
@@ -39,6 +39,23 @@ The button at the top of the plugin rail switches the theme: each click moves fr
|
||||
|
||||
When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
As in WinterCMS, a shortcut presses a button on the screen. Ctrl and Cmd are both accepted on every platform; the tooltips name Cmd (⌘) on macOS.
|
||||
|
||||
| Key | Where | What |
|
||||
|-----|-------|------|
|
||||
| Ctrl+S | Record forms, settings forms, My account | Save; on a create form, create |
|
||||
| Ctrl+Enter | Record forms, settings forms | Save and close; on a create form, create and close |
|
||||
| Ctrl+Shift+S | Create forms | Create and new (see [Forms](forms.md)) |
|
||||
| Esc | Record forms | Cancel; asks first when there are unsaved changes |
|
||||
| / | Lists with a search box | Go to the search box |
|
||||
| ? | Every screen | Show the shortcuts of the current screen |
|
||||
|
||||
A shortcut does what its button does, and nothing while the button is disabled (while saving, while loading, with an upload in progress). The save keys also work while typing in a field, a code field or the rich editor. There, on a record form, Ctrl+Enter saves and closes instead of inserting a line break (Shift+Enter still does), and on a create form Ctrl+Shift+S creates and starts a new record instead of toggling strikethrough; this is a known trade-off. Ctrl+S never opens the browser's Save page dialog.
|
||||
|
||||
Esc, / and ? do nothing while typing in a field, or while a dialog, popover or menu is open; those close on Esc themselves. When a dialog with a form is open (a related record, a pivot, the record picker, file details, a new API token), Ctrl+S and Ctrl+Enter submit that dialog and never the form underneath. Settings forms and My account have no unsaved-changes question, so Esc does not leave them. Save buttons show their shortcut in their tooltip.
|
||||
|
||||
## Types from OpenAPI
|
||||
|
||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:
|
||||
|
||||
Reference in New Issue
Block a user