677317244e
The Enter handler listened in the bubbling phase, so Vuetify's own input handler (which opens the menu on Enter) fired first and my accept logic saw the menu already opening and bailed — Enter popped the dropdown instead of submitting. Bind it in the capture phase so it runs first, and stop the event when a fandom is already selected so Vuetify never reopens the menu (operator-flagged 2026-06-07). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.7 KiB
Vue
126 lines
4.7 KiB
Vue
<template>
|
|
<v-card>
|
|
<v-card-title>Pick a fandom</v-card-title>
|
|
<v-card-text>
|
|
<!-- Keyboard flow (operator-specified 2026-06-07):
|
|
1. Focus lands here (driven by the dialog's @after-enter calling the
|
|
exposed focusSearch — `autofocus` is unreliable inside a v-dialog
|
|
because the focus-trap activates after mount and steals it).
|
|
2. Tab moves to the "new fandom" field below.
|
|
3. Selecting a fandom (click, or arrow+Enter to pick from the menu)
|
|
leaves it in the field; a SECOND Enter (menu closed) accepts it.
|
|
We bind v-model:menu so the Enter handler can tell "pick from the open
|
|
menu" (let Vuetify handle) apart from "accept the chosen value". -->
|
|
<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"
|
|
@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;">
|
|
<!-- Enter creates; on success focus returns to the dropdown (onCreate),
|
|
so the operator can immediately Enter-to-accept the new fandom. -->
|
|
<v-text-field
|
|
ref="newNameRef"
|
|
v-model="newName" placeholder="New fandom name"
|
|
density="compact" hide-details
|
|
@keydown.enter.prevent="onCreate"
|
|
/>
|
|
<v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn>
|
|
</div>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<!-- Not every character belongs to a fandom (original characters,
|
|
unsorted, etc.). "No fandom" creates the character unassigned;
|
|
a fandom can still be set later from the chip's kebab menu. -->
|
|
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
|
|
<v-spacer />
|
|
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
|
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { nextTick, onMounted, ref } from 'vue'
|
|
import { useTagStore } from '../../stores/tags.js'
|
|
|
|
const emit = defineEmits(['confirm', 'cancel'])
|
|
const store = useTagStore()
|
|
|
|
const selectedId = ref(null)
|
|
const newName = ref('')
|
|
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 instead of being swallowed as a menu interaction.
|
|
menuOpen.value = false
|
|
})
|
|
}
|
|
defineExpose({ focusSearch })
|
|
|
|
async function onCreate () {
|
|
const name = newName.value.trim()
|
|
if (!name) return
|
|
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 accepts it (operator-specified flow 2026-06-07).
|
|
focusSearch()
|
|
}
|
|
|
|
function onConfirm () {
|
|
const f = store.fandomCache.find(x => x.id === selectedId.value)
|
|
if (f) emit('confirm', f)
|
|
}
|
|
|
|
// Enter on the search field — bound in the CAPTURE phase so this runs BEFORE
|
|
// Vuetify's own input keydown handler (which opens the menu on Enter). When the
|
|
// menu is open, let Vuetify pick the highlighted item (it sets selectedId +
|
|
// closes the menu). When it's closed and a fandom is already chosen, accept it
|
|
// and stop the event so Vuetify never (re)opens the menu — that re-open was the
|
|
// bug: Enter popped the dropdown instead of submitting (operator-flagged
|
|
// 2026-06-07).
|
|
function onSearchEnter (e) {
|
|
if (menuOpen.value) return
|
|
if (selectedId.value != null) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
onConfirm()
|
|
}
|
|
}
|
|
|
|
// 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?.())
|
|
}
|
|
|
|
// Create the character with no fandom. Emits null so the caller knows this
|
|
// was a deliberate "unassigned", not a cancel.
|
|
function onNoFandom () {
|
|
emit('confirm', null)
|
|
}
|
|
</script>
|