b79708524e
Operator-specified flow for character-tag creation: focus starts in the fandom search dropdown; Tab moves to the new-fandom field where Enter creates; creating fills the dropdown and returns focus there; Enter in the dropdown accepts the selection. - Drive focus from the dialog's @after-enter (autofocus is unreliable inside a v-dialog — the focus-trap steals it post-mount); FandomPicker exposes focusSearch. - Drop the @update:model-value auto-confirm that closed the dialog the instant selectedId was set — that's what broke create-then-accept (creating set the value and immediately confirmed). Enter now accepts (menu-closed + value), while an open menu lets Vuetify pick the highlighted item first. - Tab from search → new-fandom field; Enter there creates, then focus returns to the dropdown for a single Enter-to-accept. - Restore focus to the tag input after the dialog confirms/cancels so the keyboard flow continues into the next tag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.3 KiB
Vue
118 lines
4.3 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="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. When the menu is open Vuetify is picking the
|
|
// highlighted item (it sets selectedId + closes the menu) — don't also accept
|
|
// on that keystroke. With the menu closed and a value chosen, Enter accepts.
|
|
function onSearchEnter () {
|
|
if (menuOpen.value) return
|
|
if (selectedId.value != null) 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>
|