fix(fandom): match change-fandom modal focus + Enter to FandomPicker
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:
@@ -3,20 +3,29 @@
|
|||||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<template v-if="!collision">
|
<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
|
<v-autocomplete
|
||||||
|
ref="fandomRef"
|
||||||
v-model="selectedId"
|
v-model="selectedId"
|
||||||
|
v-model:menu="menuOpen"
|
||||||
:items="store.fandomCache"
|
:items="store.fandomCache"
|
||||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||||
label="Fandom" clearable density="compact"
|
label="Fandom" clearable density="compact"
|
||||||
autofocus
|
|
||||||
:hint="selectedId == null
|
:hint="selectedId == null
|
||||||
? 'No fandom — the character will be unassigned.' : ''"
|
? 'No fandom — the character will be unassigned.' : ''"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
|
@keydown.enter.capture="onSearchEnter"
|
||||||
|
@keydown.tab="onSearchTab"
|
||||||
/>
|
/>
|
||||||
<v-divider class="my-3" />
|
<v-divider class="my-3" />
|
||||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||||
<div class="d-flex" style="gap: 8px;">
|
<div class="d-flex" style="gap: 8px;">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
ref="newNameRef"
|
||||||
v-model="newName" placeholder="New fandom name"
|
v-model="newName" placeholder="New fandom name"
|
||||||
density="compact" hide-details
|
density="compact" hide-details
|
||||||
@keydown.enter.prevent="onCreate"
|
@keydown.enter.prevent="onCreate"
|
||||||
@@ -71,7 +80,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
|
||||||
const props = defineProps({ tag: { type: Object, required: true } })
|
const props = defineProps({ tag: { type: Object, required: true } })
|
||||||
@@ -84,10 +93,25 @@ const newName = ref('')
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const collision = 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).
|
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||||
onMounted(() => store.loadFandoms())
|
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() {
|
async function onCreate() {
|
||||||
const name = newName.value.trim()
|
const name = newName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -97,6 +121,9 @@ async function onCreate() {
|
|||||||
const f = await store.createFandom(name)
|
const f = await store.createFandom(name)
|
||||||
selectedId.value = f.id
|
selectedId.value = f.id
|
||||||
newName.value = ''
|
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) {
|
} catch (e) {
|
||||||
error.value = e.message || String(e)
|
error.value = e.message || String(e)
|
||||||
} finally {
|
} 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) {
|
async function save(merge) {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|||||||
@@ -34,8 +34,12 @@
|
|||||||
/>
|
/>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<v-dialog v-model="fandomDialog" max-width="460">
|
<v-dialog
|
||||||
|
v-model="fandomDialog" max-width="460"
|
||||||
|
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||||
|
>
|
||||||
<FandomSetDialog
|
<FandomSetDialog
|
||||||
|
ref="fandomSetRef"
|
||||||
v-if="fandomTarget" :tag="fandomTarget"
|
v-if="fandomTarget" :tag="fandomTarget"
|
||||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||||
/>
|
/>
|
||||||
@@ -98,6 +102,7 @@ async function onRenamed() {
|
|||||||
|
|
||||||
const fandomDialog = ref(false)
|
const fandomDialog = ref(false)
|
||||||
const fandomTarget = ref(null)
|
const fandomTarget = ref(null)
|
||||||
|
const fandomSetRef = ref(null)
|
||||||
function openSetFandom(tag) {
|
function openSetFandom(tag) {
|
||||||
fandomTarget.value = tag
|
fandomTarget.value = tag
|
||||||
fandomDialog.value = true
|
fandomDialog.value = true
|
||||||
|
|||||||
@@ -87,8 +87,12 @@
|
|||||||
@confirm="onDeleteTagConfirm"
|
@confirm="onDeleteTagConfirm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
<v-dialog
|
||||||
|
v-model="fandomDialogOpen" max-width="460"
|
||||||
|
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||||
|
>
|
||||||
<FandomSetDialog
|
<FandomSetDialog
|
||||||
|
ref="fandomSetRef"
|
||||||
v-if="fandomTarget" :tag="fandomTarget"
|
v-if="fandomTarget" :tag="fandomTarget"
|
||||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||||
/>
|
/>
|
||||||
@@ -152,6 +156,7 @@ function openTag(tagId) {
|
|||||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||||
const fandomDialogOpen = ref(false)
|
const fandomDialogOpen = ref(false)
|
||||||
const fandomTarget = ref(null)
|
const fandomTarget = ref(null)
|
||||||
|
const fandomSetRef = ref(null)
|
||||||
function onSetFandom(card) {
|
function onSetFandom(card) {
|
||||||
fandomTarget.value = card
|
fandomTarget.value = card
|
||||||
fandomDialogOpen.value = true
|
fandomDialogOpen.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user