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>
This commit is contained in:
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user