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:
Jakub Zych
2026-10-22 21:03:30 +02:00
parent 49accf675e
commit 101cad0365
12 changed files with 163 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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