feat(modal): keyboard-friendly tagging — fandom dialogs, Tab-accept, jump hotkey, cheatsheet
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user