fix(modal): tag-chip kebab + ESC-after-accept + autocomplete scroll-into-view
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:
@@ -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() }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user