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>
|
||||
Reference in New Issue
Block a user