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.
|
// own keystrokes.
|
||||||
function onKeyDown(ev) {
|
function onKeyDown(ev) {
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
// Escape closes the modal even from inside a text input — that's
|
// Escape closes the modal even from inside a text input — the universal
|
||||||
// the universal "get me out of here" expectation, and the
|
// "get me out of here" expectation; the autofocused tag field would
|
||||||
// autofocused tag-entry field would otherwise trap focus with no
|
// otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the
|
||||||
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
|
// keystroke originates INSIDE an open Vuetify overlay's content (a rename/
|
||||||
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
|
// fandom/alias dialog, or a kebab menu), let that overlay handle its own
|
||||||
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
|
// Esc and don't close the whole modal.
|
||||||
// overlay's own Esc handling fire instead of closing the whole
|
//
|
||||||
// modal mid-interaction. Vuetify marks open overlays with
|
// #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay
|
||||||
// `.v-overlay--active`.
|
// anywhere in the DOM and suppressed the close — so a lingering overlay
|
||||||
// EXCLUDE tooltips (`.v-tooltip`): they're also `.v-overlay--active` while
|
// after accepting a suggestion (focus drops to <body>, not into any
|
||||||
// shown, so one lingering after a hover/click (e.g. just after accepting a
|
// overlay) wrongly blocked Esc. Keying off the event's origin instead means
|
||||||
// suggested tag) wrongly suppressed the close (#700). Only real interactive
|
// a stray overlay no longer traps the modal: only an Esc pressed from
|
||||||
// overlays (menus/dialogs) should keep ESC from closing the modal.
|
// within overlay content defers to that overlay.
|
||||||
if (document.querySelector('.v-overlay--active:not(.v-tooltip)')) return
|
if (ev.target?.closest?.('.v-overlay__content')) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
emit('close')
|
emit('close')
|
||||||
} else if (ev.key === 'ArrowLeft') {
|
} else if (ev.key === 'ArrowLeft') {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
/>
|
/>
|
||||||
<v-list
|
<v-list
|
||||||
v-if="hits.length || allowCreate"
|
v-if="hits.length || allowCreate"
|
||||||
|
ref="listRef"
|
||||||
density="compact" class="fc-tag-autocomplete__list"
|
density="compact" class="fc-tag-autocomplete__list"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
@@ -85,6 +86,7 @@ onMounted(() => {
|
|||||||
const query = ref('')
|
const query = ref('')
|
||||||
const hits = ref([])
|
const hits = ref([])
|
||||||
const highlight = ref(0)
|
const highlight = ref(0)
|
||||||
|
const listRef = ref(null)
|
||||||
const fandomDialog = ref(false)
|
const fandomDialog = ref(false)
|
||||||
let pendingNewName = null
|
let pendingNewName = null
|
||||||
|
|
||||||
@@ -137,6 +139,14 @@ function moveHighlight (delta) {
|
|||||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
highlight.value = (highlight.value + delta + total) % total
|
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() }
|
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">
|
<aside class="fc-tag-panel" aria-label="Tags for this image">
|
||||||
<h3 class="fc-tag-panel__title">Tags</h3>
|
<h3 class="fc-tag-panel__title">Tags</h3>
|
||||||
<div class="fc-tag-panel__chips">
|
<div class="fc-tag-panel__chips">
|
||||||
<!-- #711: the kebab + its menu used to be NESTED inside the v-chip, which
|
<TagChip
|
||||||
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
|
|
||||||
v-for="tag in modal.current?.tags || []"
|
v-for="tag in modal.current?.tags || []"
|
||||||
:key="tag.id" class="fc-tag-panel__chip"
|
:key="tag.id" :tag="tag"
|
||||||
>
|
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
|
||||||
<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>
|
|
||||||
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
|
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,23 +45,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import TagChip from './TagChip.vue'
|
||||||
import TagAutocomplete from './TagAutocomplete.vue'
|
import TagAutocomplete from './TagAutocomplete.vue'
|
||||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||||
import TagRenameDialog from './TagRenameDialog.vue'
|
import TagRenameDialog from './TagRenameDialog.vue'
|
||||||
import FandomSetDialog from './FandomSetDialog.vue'
|
import FandomSetDialog from './FandomSetDialog.vue'
|
||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const store = useTagStore()
|
|
||||||
const errorMsg = ref(null)
|
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) {
|
async function onRemove(tagId) {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
try { await modal.removeTag(tagId) }
|
try { await modal.removeTag(tagId) }
|
||||||
@@ -148,7 +108,4 @@ async function onFandomUpdated() {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user