fix(modal): keyboard focus flow for the Pick-a-fandom dialog
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

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>
This commit is contained in:
2026-06-07 11:49:28 -04:00
parent 1226d3b23a
commit b79708524e
2 changed files with 90 additions and 20 deletions
+64 -16
View File
@@ -2,22 +2,37 @@
<v-card> <v-card>
<v-card-title>Pick a fandom</v-card-title> <v-card-title>Pick a fandom</v-card-title>
<v-card-text> <v-card-text>
<!-- A2/A3 (2026-06-07): autofocus so the operator types immediately, and <!-- Keyboard flow (operator-specified 2026-06-07):
picking a fandom (keyboard: type arrow Enter, or a click) confirms 1. Focus lands here (driven by the dialog's @after-enter calling the
in one step selecting IS the decision in this dialog. --> 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 <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-title="(f) => f.name"
:item-value="(f) => f.id" :item-value="(f) => f.id"
label="Fandom" clearable density="compact" label="Fandom" clearable density="compact"
autofocus @keydown.enter="onSearchEnter"
@update:model-value="onSelect" @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-model="newName" placeholder="New fandom name" density="compact" hide-details /> <!-- 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> <v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn>
</div> </div>
</v-card-text> </v-card-text>
@@ -34,7 +49,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 emit = defineEmits(['confirm', 'cancel']) const emit = defineEmits(['confirm', 'cancel'])
@@ -42,28 +57,61 @@ const store = useTagStore()
const selectedId = ref(null) const selectedId = ref(null)
const newName = ref('') 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). // Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms()) onMounted(() => store.loadFandoms())
async function onCreate() { // Exposed so the parent dialog can focus the search field on @after-enter —
const f = await store.createFandom(newName.value.trim()) // 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 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 accepts it (operator-specified flow 2026-06-07).
focusSearch()
} }
function onConfirm() {
function onConfirm () {
const f = store.fandomCache.find(x => x.id === selectedId.value) const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Picking a fandom (keyboard Enter or click) confirms immediately. Ignore the
// clear action (null) so clearing the field doesn't fire a confirm. // Enter on the search field. When the menu is open Vuetify is picking the
function onSelect(id) { // highlighted item (it sets selectedId + closes the menu) — don't also accept
if (id == null) return // on that keystroke. With the menu closed and a value chosen, Enter accepts.
onConfirm() 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 // Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel. // was a deliberate "unassigned", not a cancel.
function onNoFandom() { function onNoFandom () {
emit('confirm', null) emit('confirm', null)
} }
</script> </script>
@@ -48,8 +48,17 @@
</v-list-item> </v-list-item>
</v-list> </v-list>
<v-dialog v-model="fandomDialog" max-width="480"> <!-- @after-enter is the reliable moment to focus the picker: the dialog's
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" /> open transition + focus-trap have settled, so focusSearch lands in the
fandom field instead of fighting the trap (operator-flagged 2026-06-07). -->
<v-dialog
v-model="fandomDialog" max-width="480"
@after-enter="fandomPickerRef?.focusSearch?.()"
>
<FandomPicker
ref="fandomPickerRef"
@confirm="onFandomChosen" @cancel="onFandomCancel"
/>
</v-dialog> </v-dialog>
</div> </div>
</template> </template>
@@ -74,10 +83,11 @@ const store = useTagStore()
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is // the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
// safe on plain HTTP (not a secure-context API). // safe on plain HTTP (not a secure-context API).
const inputRef = ref(null) const inputRef = ref(null)
onMounted(() => { function focusInput () {
if (window.matchMedia?.('(max-width: 900px)')?.matches) return if (window.matchMedia?.('(max-width: 900px)')?.matches) return
nextTick(() => inputRef.value?.focus?.()) nextTick(() => inputRef.value?.focus?.())
}) }
onMounted(focusInput)
// Single text input; no kind dropdown. Client-side mirror of the // Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with // backend's parse_kind_prefix lives below — kept in sync with
@@ -89,6 +99,7 @@ const hits = ref([])
const highlight = ref(0) const highlight = ref(0)
const listRef = ref(null) const listRef = ref(null)
const fandomDialog = ref(false) const fandomDialog = ref(false)
const fandomPickerRef = ref(null)
let pendingNewName = null let pendingNewName = null
const KNOWN_KINDS = new Set([ const KNOWN_KINDS = new Set([
@@ -178,6 +189,17 @@ function onFandomChosen (fandom) {
}) })
pendingNewName = null pendingNewName = null
reset() reset()
// Return focus to the tag input so the keyboard flow continues straight into
// the next tag instead of dropping to <body> (operator-flagged 2026-06-07).
focusInput()
}
// Cancelling the fandom dialog drops the pending character and hands focus back
// to the tag input, same as a successful pick.
function onFandomCancel () {
fandomDialog.value = false
pendingNewName = null
focusInput()
} }
function onEnter () { function onEnter () {