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-title>Pick a fandom</v-card-title>
<v-card-text>
<!-- A2/A3 (2026-06-07): autofocus so the operator types immediately, and
picking a fandom (keyboard: type arrow Enter, or a click) confirms
in one step selecting IS the decision in this dialog. -->
<!-- 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"
autofocus
@update:model-value="onSelect"
@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;">
<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>
</div>
</v-card-text>
@@ -34,7 +49,7 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
const emit = defineEmits(['confirm', 'cancel'])
@@ -42,28 +57,61 @@ 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())
async function onCreate() {
const f = await store.createFandom(newName.value.trim())
// 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() {
function onConfirm () {
const f = store.fandomCache.find(x => x.id === selectedId.value)
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.
function onSelect(id) {
if (id == null) return
onConfirm()
// 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() {
function onNoFandom () {
emit('confirm', null)
}
</script>
@@ -48,8 +48,17 @@
</v-list-item>
</v-list>
<v-dialog v-model="fandomDialog" max-width="480">
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" />
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
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>
</div>
</template>
@@ -74,10 +83,11 @@ const store = useTagStore()
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
// safe on plain HTTP (not a secure-context API).
const inputRef = ref(null)
onMounted(() => {
function focusInput () {
if (window.matchMedia?.('(max-width: 900px)')?.matches) return
nextTick(() => inputRef.value?.focus?.())
})
}
onMounted(focusInput)
// Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with
@@ -89,6 +99,7 @@ const hits = ref([])
const highlight = ref(0)
const listRef = ref(null)
const fandomDialog = ref(false)
const fandomPickerRef = ref(null)
let pendingNewName = null
const KNOWN_KINDS = new Set([
@@ -178,6 +189,17 @@ function onFandomChosen (fandom) {
})
pendingNewName = null
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 () {