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

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