fix(modal): tag-chip kebab + ESC-after-accept + autocomplete scroll-into-view
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s

Two regressions the operator re-flagged (the earlier "fixes" didn't work):

#711 tag-chip kebab: TagPanel's kebab used the #activator + v-bind="props"
v-menu pattern — the exact pattern SuggestionItem's own comment documents as
NEVER toggling inside the teleported ImageViewer modal. Extracted TagChip.vue
using the proven explicit pattern (activator="parent" + :open-on-click="false"
+ a manual v-model), mirroring the working suggestion kebab. Now opens.

#700 ESC-after-accept: the guard suppressed close whenever ANY non-tooltip
overlay was active anywhere in the DOM, so a stray overlay after accepting a
suggestion (focus drops to <body>) blocked Esc. Now key off the event origin —
only defer to an overlay when Esc is pressed from INSIDE its content
(ev.target.closest('.v-overlay__content')); a stray overlay no longer traps the
modal, and dialogs/menus still handle their own Esc.

A1: TagAutocomplete arrow-nav now scrollIntoView's the highlighted row — the
list is capped at 240px and arrowing past the fold left the active item
off-screen (operator-flagged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:42:30 -04:00
parent 4c42a15fa1
commit 22dc516dc7
4 changed files with 90 additions and 62 deletions
+14 -14
View File
@@ -97,20 +97,20 @@ let prevBodyOverflow = null
// own keystrokes.
function onKeyDown(ev) {
if (ev.key === 'Escape') {
// Escape closes the modal even from inside a text input — that's
// the universal "get me out of here" expectation, and the
// autofocused tag-entry field would otherwise trap focus with no
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
// overlay's own Esc handling fire instead of closing the whole
// modal mid-interaction. Vuetify marks open overlays with
// `.v-overlay--active`.
// EXCLUDE tooltips (`.v-tooltip`): they're also `.v-overlay--active` while
// shown, so one lingering after a hover/click (e.g. just after accepting a
// suggested tag) wrongly suppressed the close (#700). Only real interactive
// overlays (menus/dialogs) should keep ESC from closing the modal.
if (document.querySelector('.v-overlay--active:not(.v-tooltip)')) return
// Escape closes the modal even from inside a text input — the universal
// "get me out of here" expectation; the autofocused tag field would
// otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the
// keystroke originates INSIDE an open Vuetify overlay's content (a rename/
// fandom/alias dialog, or a kebab menu), let that overlay handle its own
// Esc and don't close the whole modal.
//
// #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay
// anywhere in the DOM and suppressed the close — so a lingering overlay
// after accepting a suggestion (focus drops to <body>, not into any
// overlay) wrongly blocked Esc. Keying off the event's origin instead means
// a stray overlay no longer traps the modal: only an Esc pressed from
// within overlay content defers to that overlay.
if (ev.target?.closest?.('.v-overlay__content')) return
ev.preventDefault()
emit('close')
} else if (ev.key === 'ArrowLeft') {
@@ -12,6 +12,7 @@
/>
<v-list
v-if="hits.length || allowCreate"
ref="listRef"
density="compact" class="fc-tag-autocomplete__list"
>
<v-list-item
@@ -85,6 +86,7 @@ onMounted(() => {
const query = ref('')
const hits = ref([])
const highlight = ref(0)
const listRef = ref(null)
const fandomDialog = ref(false)
let pendingNewName = null
@@ -137,6 +139,14 @@ function moveHighlight (delta) {
const total = hits.value.length + (allowCreate.value ? 1 : 0)
if (total === 0) return
highlight.value = (highlight.value + delta + total) % total
// Keep the highlighted row visible — the list is capped at 240px and arrowing
// past the fold otherwise left the active item off-screen (operator-flagged
// 2026-06-07). block:'nearest' scrolls the minimum needed.
nextTick(() => {
listRef.value?.$el
?.querySelector('.v-list-item--active')
?.scrollIntoView({ block: 'nearest' })
})
}
function onPick (hit) { emit('pick-existing', hit); reset() }
+61
View File
@@ -0,0 +1,61 @@
<template>
<!-- One tag chip + its kebab. The kebab uses the SAME explicit-menu pattern
as SuggestionItem (activator="parent" + :open-on-click="false" + a manual
v-model), because the #activator / v-bind="props" pattern never toggles a
v-menu inside the teleported ImageViewer modal (#711, re-fixed 2026-06-07
after the first attempt used the broken pattern). -->
<span class="fc-tag-chip">
<v-chip
size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
</v-chip>
<span class="fc-tag-chip__menu-wrap">
<v-btn
class="fc-tag-chip__kebab"
icon="mdi-dots-vertical" size="x-small"
variant="text" density="comfortable"
:aria-label="`More actions for ${tag.name}`"
@click.stop="menuOpen = !menuOpen"
/>
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false">
<v-list density="compact">
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
<v-list-item-title>Set fandom</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</span>
</template>
<script setup>
import { ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom'])
const store = useTagStore()
const menuOpen = ref(false)
const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline',
}
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
</script>
<style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
.fc-tag-chip__menu-wrap { display: inline-flex; align-items: center; }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
</style>
+5 -48
View File
@@ -2,43 +2,11 @@
<aside class="fc-tag-panel" aria-label="Tags for this image">
<h3 class="fc-tag-panel__title">Tags</h3>
<div class="fc-tag-panel__chips">
<!-- #711: the kebab + its menu used to be NESTED inside the v-chip, which
swallowed/mis-routed the click and mis-anchored the (teleported) menu
so it never opened. Render the kebab as a SIBLING of the chip and use
the standard v-menu activator slot Vuetify wires the click and
stacks the overlay above the modal natively. -->
<span
<TagChip
v-for="tag in modal.current?.tags || []"
:key="tag.id" class="fc-tag-panel__chip"
>
<v-chip
size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
@click:close="onRemove(tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
</v-chip>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props" icon="mdi-dots-vertical" size="x-small"
variant="text" density="comfortable"
class="fc-tag-panel__kebab" @click.stop
/>
</template>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
>
<v-list-item-title>Set fandom</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
:key="tag.id" :tag="tag"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
/>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -77,23 +45,15 @@
<script setup>
import { ref } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { useTagStore } from '../../stores/tags.js'
import TagChip from './TagChip.vue'
import TagAutocomplete from './TagAutocomplete.vue'
import SuggestionsPanel from './SuggestionsPanel.vue'
import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const store = useTagStore()
const errorMsg = ref(null)
const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
}
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
async function onRemove(tagId) {
errorMsg.value = null
try { await modal.removeTag(tagId) }
@@ -148,7 +108,4 @@ async function onFandomUpdated() {
margin-bottom: 12px;
}
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.fc-tag-panel__chip { display: inline-flex; align-items: center; gap: 1px; }
.fc-tag-panel__kebab { opacity: 0.7; }
.fc-tag-panel__chip:hover .fc-tag-panel__kebab { opacity: 1; }
</style>