feat(explore/tags): return focus to the tag input after every action
Explore is a rapid walk-and-tag surface, so focus must keep returning to the tag input with no extra click (operator-asked 2026-06-26). Two gaps closed: - Navigation hardening: refocus on every focused-image change (neighbour click, breadcrumb, Random image, seed) now runs nextTick → requestAnimationFrame, so it lands AFTER the post-navigation re-render/paint instead of being stolen back by the neighbour-grid re-render. - All tag actions refocus, in both Explore and the modal: tag add (existing/new) and remove now hand focus back like accept-suggestion already did; and the rename + fandom-assignment dialogs refocus on @after-leave (fires after Vuetify's own focus-return to the activator, so ours wins). TagAutocomplete's mobile guard is preserved throughout (no soft-keyboard pop on touch). Modal behaviour gains the same stickier focus — consistent, low-risk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,15 @@
|
|||||||
@accepted="focusTagInput"
|
@accepted="focusTagInput"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-dialog v-model="renameDialog" max-width="420">
|
<!-- @after-leave: when either dialog finishes closing (apply OR cancel),
|
||||||
|
hand focus back to the tag input. Fires after Vuetify's own
|
||||||
|
focus-return to the activator, so ours wins; keeps the keyboard flow on
|
||||||
|
the input (operator-asked 2026-06-26). Mobile guard preserved via
|
||||||
|
focusTagInput → TagAutocomplete. -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="renameDialog" max-width="420"
|
||||||
|
@after-leave="focusTagInput"
|
||||||
|
>
|
||||||
<TagRenameDialog
|
<TagRenameDialog
|
||||||
v-if="renameTarget"
|
v-if="renameTarget"
|
||||||
:tag="renameTarget"
|
:tag="renameTarget"
|
||||||
@@ -41,6 +49,7 @@
|
|||||||
<v-dialog
|
<v-dialog
|
||||||
v-model="fandomDialog" max-width="460"
|
v-model="fandomDialog" max-width="460"
|
||||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||||
|
@after-leave="focusTagInput"
|
||||||
>
|
>
|
||||||
<FandomSetDialog
|
<FandomSetDialog
|
||||||
ref="fandomSetRef"
|
ref="fandomSetRef"
|
||||||
@@ -92,19 +101,22 @@ async function onNavigate(tag) {
|
|||||||
function focusTagInput() { tagInputRef.value?.focus?.() }
|
function focusTagInput() { tagInputRef.value?.focus?.() }
|
||||||
defineExpose({ focusTagInput })
|
defineExpose({ focusTagInput })
|
||||||
|
|
||||||
|
// Every tag mutation hands focus back to the input so the operator can keep
|
||||||
|
// typing the next tag without re-clicking — matches the accept-suggestion flow
|
||||||
|
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
|
||||||
async function onRemove(tagId) {
|
async function onRemove(tagId) {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
try { await host.removeTag(tagId) }
|
try { await host.removeTag(tagId); focusTagInput() }
|
||||||
catch (e) { errorMsg.value = e.message }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
async function onPickExisting(hit) {
|
async function onPickExisting(hit) {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
try { await host.addExistingTag(hit.id) }
|
try { await host.addExistingTag(hit.id); focusTagInput() }
|
||||||
catch (e) { errorMsg.value = e.message }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
async function onPickNew(payload) {
|
async function onPickNew(payload) {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
try { await host.createAndAdd(payload) }
|
try { await host.createAndAdd(payload); focusTagInput() }
|
||||||
catch (e) { errorMsg.value = e.message }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||||
|
|||||||
@@ -130,14 +130,18 @@ const seeding = ref(false)
|
|||||||
const seedError = ref(null)
|
const seedError = ref(null)
|
||||||
const tagPanelRef = ref(null)
|
const tagPanelRef = ref(null)
|
||||||
|
|
||||||
// Auto-focus the tag input whenever the focused image changes (seed + every
|
// Auto-focus the tag input after any action so tagging needs no extra click —
|
||||||
// walk) so tagging needs no extra click — the whole point of the workspace
|
// the whole point of the workspace (operator-asked 2026-06-26). nextTick waits
|
||||||
// (operator-asked 2026-06-26). Reuses TagPanel/TagAutocomplete's focus, which
|
// for the post-navigation re-render, then rAF lands the focus AFTER paint so a
|
||||||
// keeps its own mobile guard (no soft-keyboard pop on touch).
|
// neighbour/breadcrumb click that re-renders the grid can't steal it back.
|
||||||
watch(
|
// Reuses TagPanel/TagAutocomplete's focus, which keeps its mobile guard (no
|
||||||
() => store.currentImageId,
|
// soft-keyboard pop on touch).
|
||||||
(id) => { if (id != null) nextTick(() => tagPanelRef.value?.focusTagInput?.()) },
|
function refocusTag () {
|
||||||
)
|
nextTick(() => requestAnimationFrame(() => tagPanelRef.value?.focusTagInput?.()))
|
||||||
|
}
|
||||||
|
// Every focused-image change (seed + every walk: neighbour click, breadcrumb,
|
||||||
|
// Random image).
|
||||||
|
watch(() => store.currentImageId, (id) => { if (id != null) refocusTag() })
|
||||||
|
|
||||||
// The route is the source of truth. With an anchor, walk it; without one (the
|
// The route is the source of truth. With an anchor, walk it; without one (the
|
||||||
// bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
|
// bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
|
||||||
|
|||||||
Reference in New Issue
Block a user