refactor(ui): consolidate 7 hand-rolled kebabs into one KebabMenu (DRY pattern sweep)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m18s

First pattern-consistency DRY pass (process #594). The overflow kebab was
hand-rolled 7 ways in two divergent activator strategies — Pattern A
(#activator + v-bind) which silently breaks inside the teleported image modal
(#711), and Pattern B (manual v-model + activator=parent + open-on-click=false +
z-index 2400) the modal kebabs needed as a workaround.

New <KebabMenu> (components/common) bakes in the modal-safe strategy
UNIVERSALLY, so every kebab works in modal and non-modal contexts — folding the
latent #711-class bug fix into all five Pattern-A sites. Menu items go in the
default slot; variations (size/variant/location/label/min-width) are props.

Adopted across all 7: TagChip, SuggestionItem, TagCard, SeriesView card,
SeriesManageView, BackupRunsTable, SourceActions. Exhaustiveness (§8b):
mdi-dots-vertical now lives only in KebabMenu. Labeled dropdowns / nav menus /
filter popovers are a different concept and left alone. Seeded the pattern
catalog so new code reuses the primitive. Test: KebabMenu renders slot items +
trigger label/glyph + presentational props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:25:11 -04:00
parent d5d23a92f2
commit c774042a85
9 changed files with 211 additions and 196 deletions
@@ -19,46 +19,29 @@
>
Accept
</v-btn>
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
prior `#activator` + `v-bind="props"` path never toggled the menu
inside this teleported modal, while v-model-driven overlays (the
dialogs in this modal) work fine. So drive the menu explicitly:
the button toggles `menuOpen` with @click.stop (also shields any
parent), and `activator="parent"` anchors the menu for positioning
only — `:open-on-click="false"` keeps Vuetify's activator-click out
of it, so there's a single, reliable opener. -->
<span class="fc-suggestion__menu-wrap">
<v-btn
class="fc-suggestion__menu"
icon="mdi-dots-vertical" size="small"
variant="outlined" density="compact"
:aria-label="`More actions for ${suggestion.display_name}`"
@click.stop="menuOpen = !menuOpen"
/>
<v-menu
v-model="menuOpen" activator="parent" :open-on-click="false"
:z-index="2400"
>
<v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
teleported image modal #711). -->
<KebabMenu
class="fc-suggestion__menu" size="small" variant="outlined"
:label="`More actions for ${suggestion.display_name}`"
>
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</KebabMenu>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'dismiss'])
const menuOpen = ref(false)
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
</script>
@@ -102,11 +85,6 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em;
}
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu {
flex: 0 0 auto;
}
+12 -31
View File
@@ -1,9 +1,4 @@
<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
@@ -16,41 +11,28 @@
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></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"
/>
<!-- :z-index above the modal's 2000 — the teleported menu otherwise lands
BEHIND .fc-viewer and gets blurred by its backdrop-filter (operator
saw it ghosted behind the sidebar, 2026-06-07). THIS is what made the
kebab look dead the whole time: it opened, just underneath. -->
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false" :z-index="2400">
<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>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). -->
<KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
<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>
</KebabMenu>
</span>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom'])
const store = useTagStore()
const menuOpen = ref(false)
// Show a character's fandom inline (truncated). Falls back to a bare arrow when
// only fandom_id is known but the name wasn't resolved (older payloads).
@@ -71,7 +53,6 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
<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; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }