Files
FabledCurator/frontend/src/components/modal/FandomPicker.vue
T
bvandeusen 677317244e
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m3s
fix(modal): Enter accepts the fandom instead of reopening the dropdown
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>
2026-06-07 12:38:33 -04:00

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>