fix(fandom): match change-fandom modal focus + Enter to FandomPicker
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m6s

The 'Fandom for <character>' dialog (FandomSetDialog) used plain autofocus and
had no Enter handling, so Enter re-opened the dropdown instead of submitting —
the same bug FandomPicker already fixed. Mirror that flow: parent v-dialogs
focus the field via @after-enter→focusSearch (reliable past the focus-trap);
capture-phase Enter Saves the changed selection instead of re-opening the menu;
Tab jumps to the new-fandom field; creating a fandom returns focus to the
dropdown so a single Enter saves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:37:02 -04:00
parent 5bc8ef65ad
commit aaa375654b
3 changed files with 64 additions and 4 deletions
@@ -3,20 +3,29 @@
<v-card-title class="text-body-1">Fandom for {{ tag.name }}</v-card-title>
<v-card-text>
<template v-if="!collision">
<!-- Keyboard flow matches FandomPicker (operator-specified 2026-06-07):
focus lands here via the parent dialog's @after-enter focusSearch
(autofocus is unreliable inside a v-dialog focus-trap); Tab moves to
the new-fandom field; a SECOND Enter (menu closed) accepts the choice
by Saving instead of re-opening the dropdown. -->
<v-autocomplete
ref="fandomRef"
v-model="selectedId"
v-model:menu="menuOpen"
: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
@keydown.enter.capture="onSearchEnter"
@keydown.tab="onSearchTab"
/>
<v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p>
<div class="d-flex" style="gap: 8px;">
<v-text-field
ref="newNameRef"
v-model="newName" placeholder="New fandom name"
density="compact" hide-details
@keydown.enter.prevent="onCreate"
@@ -71,7 +80,7 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
const props = defineProps({ tag: { type: Object, required: true } })
@@ -84,10 +93,25 @@ const newName = ref('')
const busy = ref(false)
const error = ref(null)
const collision = ref(null)
const menuOpen = ref(false)
const fandomRef = ref(null)
const newNameRef = ref(null)
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms())
// Exposed so the parent dialog can focus the search field on @after-enter —
// the reliable point to grab focus (the dialog transition + focus-trap are done).
function focusSearch() {
nextTick(() => {
fandomRef.value?.focus?.()
// Keep the menu closed after a programmatic focus so the very next Enter
// accepts the value (Saves) instead of being swallowed as a menu interaction.
menuOpen.value = false
})
}
defineExpose({ focusSearch })
async function onCreate() {
const name = newName.value.trim()
if (!name) return
@@ -97,6 +121,9 @@ async function onCreate() {
const f = await store.createFandom(name)
selectedId.value = f.id
newName.value = ''
// The created fandom is now the selection; hand focus back to the dropdown
// so a single Enter saves it (matches FandomPicker's flow).
focusSearch()
} catch (e) {
error.value = e.message || String(e)
} finally {
@@ -104,6 +131,29 @@ async function onCreate() {
}
}
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
// pick the highlighted item. Menu closed with a changed selection → Save and stop
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
function onSearchEnter(e) {
if (menuOpen.value) return
if (busy.value) return
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
e.preventDefault()
e.stopPropagation()
onSave()
}
}
// Tab from the search field goes to the "new fandom" text field (operator-
// specified). Shift+Tab keeps normal reverse traversal.
function onSearchTab(e) {
if (e.shiftKey) return
e.preventDefault()
menuOpen.value = false
nextTick(() => newNameRef.value?.focus?.())
}
async function save(merge) {
busy.value = true
error.value = null
+6 -1
View File
@@ -34,8 +34,12 @@
/>
</v-dialog>
<v-dialog v-model="fandomDialog" max-width="460">
<v-dialog
v-model="fandomDialog" max-width="460"
@after-enter="fandomSetRef?.focusSearch?.()"
>
<FandomSetDialog
ref="fandomSetRef"
v-if="fandomTarget" :tag="fandomTarget"
@updated="onFandomUpdated" @cancel="fandomDialog = false"
/>
@@ -98,6 +102,7 @@ async function onRenamed() {
const fandomDialog = ref(false)
const fandomTarget = ref(null)
const fandomSetRef = ref(null)
function openSetFandom(tag) {
fandomTarget.value = tag
fandomDialog.value = true
+6 -1
View File
@@ -87,8 +87,12 @@
@confirm="onDeleteTagConfirm"
/>
<v-dialog v-model="fandomDialogOpen" max-width="460">
<v-dialog
v-model="fandomDialogOpen" max-width="460"
@after-enter="fandomSetRef?.focusSearch?.()"
>
<FandomSetDialog
ref="fandomSetRef"
v-if="fandomTarget" :tag="fandomTarget"
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
/>
@@ -152,6 +156,7 @@ function openTag(tagId) {
// Character fandom editing (dots-menu → FandomSetDialog).
const fandomDialogOpen = ref(false)
const fandomTarget = ref(null)
const fandomSetRef = ref(null)
function onSetFandom(card) {
fandomTarget.value = card
fandomDialogOpen.value = true