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>