feat(modal): keyboard-friendly tagging — fandom dialogs, Tab-accept, jump hotkey, cheatsheet
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 3m1s

Operator-requested modal/tagging keyboard improvements:
- A2/A3: fandom dialogs autofocus their autocomplete on open; in the character-
  creation FandomPicker, picking a fandom (keyboard Enter or click) confirms in
  one step. FandomSetDialog stays autofocus-only (its Save can trigger a merge).
- B5: Tab accepts the highlighted autocomplete row (standard convention).
- C9: T or / jumps focus to the tag input from anywhere in the modal.
- C8: ? toggles a keyboard cheatsheet (corner hint advertises it; Esc closes the
  cheatsheet first, then the viewer).

Builds on the same-batch regression fixes (kebab #711, ESC-after-accept #700,
autocomplete scroll-into-view). B6 (keep focus after add) is covered — the input
retains focus after adding a tag, and Esc now works after accepting a suggestion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:46:46 -04:00
parent 22dc516dc7
commit 1819caaf5b
4 changed files with 95 additions and 1 deletions
@@ -2,12 +2,17 @@
<v-card>
<v-card-title>Pick a fandom</v-card-title>
<v-card-text>
<!-- A2/A3 (2026-06-07): autofocus so the operator types immediately, and
picking a fandom (keyboard: type arrow Enter, or a click) confirms
in one step selecting IS the decision in this dialog. -->
<v-autocomplete
v-model="selectedId"
:items="store.fandomCache"
:item-title="(f) => f.name"
:item-value="(f) => f.id"
label="Fandom" clearable density="compact"
autofocus
@update:model-value="onSelect"
/>
<v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p>
@@ -50,6 +55,12 @@ function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f)
}
// Picking a fandom (keyboard Enter or click) confirms immediately. Ignore the
// clear action (null) so clearing the field doesn't fire a confirm.
function onSelect(id) {
if (id == null) return
onConfirm()
}
// Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel.
function onNoFandom() {
@@ -8,6 +8,7 @@
:items="store.fandomCache"
:item-title="(f) => f.name" :item-value="(f) => f.id"
label="Fandom" clearable density="compact"
autofocus
:hint="selectedId == null
? 'No fandom — the character will be unassigned.' : ''"
persistent-hint
+72 -1
View File
@@ -8,6 +8,25 @@
<v-icon>mdi-close</v-icon>
</button>
<!-- C8: keyboard cheatsheet. '?' toggles; the corner hint advertises it. -->
<button
class="fc-viewer__help-hint" aria-label="Keyboard shortcuts (press ?)"
@click="showHelp = !showHelp"
>?</button>
<div v-if="showHelp" class="fc-viewer__help" @click.self="showHelp = false">
<div class="fc-viewer__help-card" role="dialog" aria-label="Keyboard shortcuts">
<h3>Keyboard shortcuts</h3>
<dl>
<div><dt> / </dt><dd>Previous / next image</dd></div>
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
<div><dt> / </dt><dd>Move through tag suggestions</dd></div>
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
<div><dt>Esc</dt><dd>Close a dialog, then the viewer</dd></div>
<div><dt>?</dt><dd>Toggle this help</dd></div>
</dl>
</div>
</div>
<v-chip
v-if="integrityBadge"
:color="integrityBadge.color"
@@ -63,7 +82,7 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed } from '../../utils/textEntry.js'
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue'
import TagPanel from './TagPanel.vue'
@@ -74,6 +93,7 @@ const emit = defineEmits(['close'])
const modal = useModalStore()
const rootEl = ref(null)
const showHelp = ref(false)
const isVideo = computed(() =>
modal.current?.mime && modal.current.mime.startsWith('video/')
@@ -96,6 +116,12 @@ let prevBodyOverflow = null
// that. Filtered via isTextEntry so tag/comment inputs still get their
// own keystrokes.
function onKeyDown(ev) {
if (ev.key === 'Escape' && showHelp.value) {
// Close the cheatsheet first; a second Esc closes the modal.
ev.preventDefault()
showHelp.value = false
return
}
if (ev.key === 'Escape') {
// Escape closes the modal even from inside a text input — the universal
// "get me out of here" expectation; the autofocused tag field would
@@ -123,6 +149,14 @@ function onKeyDown(ev) {
if (!arrowNavAllowed(ev.target)) return
ev.preventDefault()
modal.goNext()
} else if ((ev.key === '/' || ev.key === 't') && !isTextEntry(ev.target)) {
// C9 (2026-06-07): jump focus to the tag input from anywhere in the modal.
const input = document.querySelector('.fc-tag-autocomplete input')
if (input) { ev.preventDefault(); input.focus() }
} else if (ev.key === '?' && !isTextEntry(ev.target)) {
// C8: toggle the keyboard cheatsheet.
ev.preventDefault()
showHelp.value = !showHelp.value
}
}
@@ -178,6 +212,43 @@ function nextFrame() {
}
.fc-viewer__nav:disabled { opacity: 0.3; cursor: not-allowed; }
.fc-viewer__close { top: 16px; right: 16px; transform: none; }
.fc-viewer__help-hint {
position: absolute; top: 16px; right: 64px; z-index: 2;
width: 32px; height: 32px; border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgb(var(--v-theme-on-surface));
font-weight: 700; cursor: pointer; opacity: 0.6;
}
.fc-viewer__help-hint:hover { opacity: 1; }
.fc-viewer__help {
position: absolute; inset: 0; z-index: 4;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.fc-viewer__help-card {
background: rgb(var(--v-theme-surface));
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 10px; padding: 20px 24px; min-width: 320px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.fc-viewer__help-card h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; margin-bottom: 12px;
color: rgb(var(--v-theme-on-surface));
}
.fc-viewer__help-card dl { display: flex; flex-direction: column; gap: 8px; }
.fc-viewer__help-card dl > div {
display: flex; gap: 16px; align-items: baseline;
}
.fc-viewer__help-card dt {
flex: 0 0 96px; text-align: right;
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: rgb(var(--v-theme-accent));
}
.fc-viewer__help-card dd {
flex: 1; font-size: 14px;
color: rgb(var(--v-theme-on-surface));
}
.fc-viewer__integrity {
position: absolute; top: 72px; right: 16px; z-index: 3;
}
@@ -8,6 +8,7 @@
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="onEnter"
@keydown.tab="onTab"
@keydown.esc="$emit('cancel')"
/>
<v-list
@@ -187,6 +188,16 @@ function onEnter () {
}
}
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the
// highlighted row (standard autocomplete convention) instead of leaving the
// field. With the list closed it falls through to normal focus traversal.
function onTab (e) {
if (hits.value.length || allowCreate.value) {
e.preventDefault()
onEnter()
}
}
function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
</script>