Files
FabledCurator/frontend/src/components/modal/FandomPicker.vue
T
bvandeusen b79708524e
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m7s
fix(modal): keyboard focus flow for the Pick-a-fandom dialog
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>
2026-06-07 11:49:28 -04:00

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>