Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps) #90
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Canonical kebab (overflow ⋮) menu — the single primitive every kebab in
|
||||||
|
the app uses (DRY pattern-consistency sweep 2026-06-09). It bakes in the
|
||||||
|
modal-safe activator strategy that the image-modal kebabs needed (#711):
|
||||||
|
a manual v-model toggled by @click.stop, `activator="parent"` for
|
||||||
|
positioning only, `:open-on-click="false"`, and a z-index above the modal
|
||||||
|
(2000) — so a kebab works identically inside a teleported modal and on a
|
||||||
|
plain page. Menu items go in the default slot; close-on-content-click
|
||||||
|
(Vuetify default) closes the menu when one is chosen. -->
|
||||||
|
<span class="fc-kebab">
|
||||||
|
<v-btn
|
||||||
|
:icon="icon"
|
||||||
|
:size="size"
|
||||||
|
:variant="variant"
|
||||||
|
:aria-label="label"
|
||||||
|
@click.stop="open = !open"
|
||||||
|
/>
|
||||||
|
<v-menu
|
||||||
|
v-model="open"
|
||||||
|
activator="parent"
|
||||||
|
:open-on-click="false"
|
||||||
|
:location="location"
|
||||||
|
:z-index="2400"
|
||||||
|
>
|
||||||
|
<v-list density="compact" :min-width="minWidth">
|
||||||
|
<slot />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, default: 'mdi-dots-vertical' },
|
||||||
|
size: { type: String, default: 'x-small' },
|
||||||
|
variant: { type: String, default: 'text' },
|
||||||
|
location: { type: String, default: 'bottom end' },
|
||||||
|
// aria-label for the trigger (every kebab should name what it acts on).
|
||||||
|
label: { type: String, default: 'More actions' },
|
||||||
|
minWidth: { type: [String, Number], default: undefined },
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-kebab { display: inline-flex; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -46,34 +46,25 @@
|
|||||||
<v-chip size="x-small" label>{{ card.kind }}</v-chip>
|
<v-chip size="x-small" label>{{ card.kind }}</v-chip>
|
||||||
<div class="fc-tagcard__meta-right">
|
<div class="fc-tagcard__meta-right">
|
||||||
<span class="fc-tagcard__count">{{ card.image_count }}</span>
|
<span class="fc-tagcard__count">{{ card.image_count }}</span>
|
||||||
<v-menu>
|
<KebabMenu class="fc-tagcard__menu" :label="`Actions for ${card.name}`">
|
||||||
<template #activator="{ props: act }">
|
<v-list-item
|
||||||
<v-btn
|
v-if="card.kind === 'character'"
|
||||||
class="fc-tagcard__menu"
|
title="Set fandom…"
|
||||||
icon="mdi-dots-vertical" size="x-small" variant="text"
|
prepend-icon="mdi-book-open-page-variant"
|
||||||
v-bind="act" @click.stop
|
@click="$emit('set-fandom', card)"
|
||||||
/>
|
/>
|
||||||
</template>
|
<v-list-item
|
||||||
<v-list density="compact">
|
title="Merge with…"
|
||||||
<v-list-item
|
prepend-icon="mdi-call-merge"
|
||||||
v-if="card.kind === 'character'"
|
@click="$emit('merge-with', card)"
|
||||||
title="Set fandom…"
|
/>
|
||||||
prepend-icon="mdi-book-open-page-variant"
|
<v-list-item
|
||||||
@click="$emit('set-fandom', card)"
|
title="Delete tag"
|
||||||
/>
|
prepend-icon="mdi-delete"
|
||||||
<v-list-item
|
base-color="error"
|
||||||
title="Merge with…"
|
@click="$emit('delete', card)"
|
||||||
prepend-icon="mdi-call-merge"
|
/>
|
||||||
@click="$emit('merge-with', card)"
|
</KebabMenu>
|
||||||
/>
|
|
||||||
<v-list-item
|
|
||||||
title="Delete tag"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
base-color="error"
|
|
||||||
@click="$emit('delete', card)"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -82,6 +73,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import KebabMenu from '../common/KebabMenu.vue'
|
||||||
|
|
||||||
const props = defineProps({ card: { type: Object, required: true } })
|
const props = defineProps({ card: { type: Object, required: true } })
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
|
|||||||
@@ -19,46 +19,29 @@
|
|||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
|
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
|
||||||
prior `#activator` + `v-bind="props"` path never toggled the menu
|
teleported image modal — #711). -->
|
||||||
inside this teleported modal, while v-model-driven overlays (the
|
<KebabMenu
|
||||||
dialogs in this modal) work fine. So drive the menu explicitly:
|
class="fc-suggestion__menu" size="small" variant="outlined"
|
||||||
the button toggles `menuOpen` with @click.stop (also shields any
|
:label="`More actions for ${suggestion.display_name}`"
|
||||||
parent), and `activator="parent"` anchors the menu for positioning
|
>
|
||||||
only — `:open-on-click="false"` keeps Vuetify's activator-click out
|
<v-list-item @click="$emit('alias', suggestion)">
|
||||||
of it, so there's a single, reliable opener. -->
|
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||||
<span class="fc-suggestion__menu-wrap">
|
</v-list-item>
|
||||||
<v-btn
|
<v-list-item @click="$emit('dismiss', suggestion)">
|
||||||
class="fc-suggestion__menu"
|
<v-list-item-title>Dismiss for this image</v-list-item-title>
|
||||||
icon="mdi-dots-vertical" size="small"
|
</v-list-item>
|
||||||
variant="outlined" density="compact"
|
</KebabMenu>
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 } })
|
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||||
defineEmits(['accept', 'alias', 'dismiss'])
|
defineEmits(['accept', 'alias', 'dismiss'])
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
|
||||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -102,11 +85,6 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
|||||||
.fc-suggestion__accept :deep(.v-btn__content) {
|
.fc-suggestion__accept :deep(.v-btn__content) {
|
||||||
font-size: 12px; letter-spacing: 0.02em;
|
font-size: 12px; letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.fc-suggestion__menu-wrap {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.fc-suggestion__menu {
|
.fc-suggestion__menu {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
<template>
|
<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">
|
<span class="fc-tag-chip">
|
||||||
<v-chip
|
<v-chip
|
||||||
size="small" closable
|
size="small" closable
|
||||||
@@ -16,41 +11,28 @@
|
|||||||
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
|
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
|
||||||
>→<template v-if="fandomLabel"> {{ fandomLabel }}</template></span>
|
>→<template v-if="fandomLabel"> {{ fandomLabel }}</template></span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<span class="fc-tag-chip__menu-wrap">
|
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
|
||||||
<v-btn
|
teleported image modal — #711). -->
|
||||||
class="fc-tag-chip__kebab"
|
<KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
|
||||||
icon="mdi-dots-vertical" size="x-small"
|
<v-list-item @click="$emit('rename', tag)">
|
||||||
variant="text" density="comfortable"
|
<v-list-item-title>Rename…</v-list-item-title>
|
||||||
:aria-label="`More actions for ${tag.name}`"
|
</v-list-item>
|
||||||
@click.stop="menuOpen = !menuOpen"
|
<v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
|
||||||
/>
|
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||||
<!-- :z-index above the modal's 2000 — the teleported menu otherwise lands
|
</v-list-item>
|
||||||
BEHIND .fc-viewer and gets blurred by its backdrop-filter (operator
|
</KebabMenu>
|
||||||
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>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import KebabMenu from '../common/KebabMenu.vue'
|
||||||
|
|
||||||
const props = defineProps({ tag: { type: Object, required: true } })
|
const props = defineProps({ tag: { type: Object, required: true } })
|
||||||
defineEmits(['remove', 'rename', 'set-fandom'])
|
defineEmits(['remove', 'rename', 'set-fandom'])
|
||||||
|
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
const menuOpen = ref(false)
|
|
||||||
|
|
||||||
// Show a character's fandom inline (truncated). Falls back to a bare arrow when
|
// 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).
|
// 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>
|
<style scoped>
|
||||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
|
.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__kebab { opacity: 0.7; }
|
||||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||||
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
||||||
|
|||||||
@@ -30,25 +30,20 @@
|
|||||||
<span v-else class="fc-muted">—</span>
|
<span v-else class="fc-muted">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<v-menu>
|
<KebabMenu :label="`Actions for backup ${r.id}`">
|
||||||
<template #activator="{ props: act }">
|
<v-list-item
|
||||||
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="act" />
|
:title="r.tag ? 'Untag' : 'Tag…'"
|
||||||
</template>
|
@click="$emit('tag', r)"
|
||||||
<v-list density="compact">
|
/>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:title="r.tag ? 'Untag' : 'Tag…'"
|
title="Restore…" :disabled="r.status !== 'ok'"
|
||||||
@click="$emit('tag', r)"
|
@click="$emit('restore', r)"
|
||||||
/>
|
/>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
title="Restore…" :disabled="r.status !== 'ok'"
|
title="Delete…"
|
||||||
@click="$emit('restore', r)"
|
@click="$emit('delete', r)"
|
||||||
/>
|
/>
|
||||||
<v-list-item
|
</KebabMenu>
|
||||||
title="Delete…"
|
|
||||||
@click="$emit('delete', r)"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!runs.length">
|
<tr v-if="!runs.length">
|
||||||
@@ -60,6 +55,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { formatRelative as fmtRelative } from '../../utils/date.js'
|
import { formatRelative as fmtRelative } from '../../utils/date.js'
|
||||||
|
import KebabMenu from '../common/KebabMenu.vue'
|
||||||
|
|
||||||
defineProps({ runs: { type: Array, default: () => [] } })
|
defineProps({ runs: { type: Array, default: () => [] } })
|
||||||
defineEmits(['restore', 'delete', 'tag'])
|
defineEmits(['restore', 'delete', 'tag'])
|
||||||
|
|||||||
@@ -26,40 +26,33 @@
|
|||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-menu location="bottom end">
|
<KebabMenu size="small" :min-width="260" label="More actions">
|
||||||
<template #activator="{ props: menuProps }">
|
<v-list-item
|
||||||
<v-btn icon size="small" variant="text" v-bind="menuProps" @click.stop>
|
v-if="isPatreon && !running"
|
||||||
<v-icon>mdi-dots-vertical</v-icon>
|
prepend-icon="mdi-backup-restore"
|
||||||
<v-tooltip activator="parent" location="top">More actions</v-tooltip>
|
@click="emit('recover', source)"
|
||||||
</v-btn>
|
>
|
||||||
</template>
|
<v-list-item-title>Recover dropped near-duplicates</v-list-item-title>
|
||||||
<v-list density="compact" min-width="260">
|
<v-list-item-subtitle>
|
||||||
<v-list-item
|
Re-fetch images previously dropped as near-dups and re-judge them
|
||||||
v-if="isPatreon && !running"
|
under the current similarity threshold
|
||||||
prepend-icon="mdi-backup-restore"
|
</v-list-item-subtitle>
|
||||||
@click="emit('recover', source)"
|
</v-list-item>
|
||||||
>
|
<v-divider v-if="isPatreon && !running" />
|
||||||
<v-list-item-title>Recover dropped near-duplicates</v-list-item-title>
|
<v-list-item
|
||||||
<v-list-item-subtitle>
|
base-color="error"
|
||||||
Re-fetch images previously dropped as near-dups and re-judge them
|
prepend-icon="mdi-delete-outline"
|
||||||
under the current similarity threshold
|
@click="emit('remove', source)"
|
||||||
</v-list-item-subtitle>
|
>
|
||||||
</v-list-item>
|
<v-list-item-title>Remove source</v-list-item-title>
|
||||||
<v-divider v-if="isPatreon && !running" />
|
</v-list-item>
|
||||||
<v-list-item
|
</KebabMenu>
|
||||||
base-color="error"
|
|
||||||
prepend-icon="mdi-delete-outline"
|
|
||||||
@click="emit('remove', source)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>Remove source</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import KebabMenu from '../common/KebabMenu.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
source: { type: Object, required: true },
|
source: { type: Object, required: true },
|
||||||
|
|||||||
@@ -67,37 +67,29 @@
|
|||||||
@click="openPicker(ch.id)"
|
@click="openPicker(ch.id)"
|
||||||
>Add pages</v-btn>
|
>Add pages</v-btn>
|
||||||
|
|
||||||
<v-menu location="bottom end">
|
<KebabMenu size="small" label="Part actions">
|
||||||
<template #activator="{ props }">
|
<v-list-item
|
||||||
<v-btn
|
prepend-icon="mdi-chevron-up" title="Move up"
|
||||||
v-bind="props" size="small" variant="text"
|
:disabled="ci === 0"
|
||||||
icon="mdi-dots-vertical" title="Part actions"
|
@click="store.moveChapter(ch.id, -1)"
|
||||||
/>
|
/>
|
||||||
</template>
|
<v-list-item
|
||||||
<v-list density="compact">
|
prepend-icon="mdi-chevron-down" title="Move down"
|
||||||
<v-list-item
|
:disabled="ci === store.chapters.length - 1"
|
||||||
prepend-icon="mdi-chevron-up" title="Move up"
|
@click="store.moveChapter(ch.id, 1)"
|
||||||
:disabled="ci === 0"
|
/>
|
||||||
@click="store.moveChapter(ch.id, -1)"
|
<v-list-item
|
||||||
/>
|
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
|
||||||
<v-list-item
|
:disabled="ci === 0"
|
||||||
prepend-icon="mdi-chevron-down" title="Move down"
|
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
|
||||||
:disabled="ci === store.chapters.length - 1"
|
/>
|
||||||
@click="store.moveChapter(ch.id, 1)"
|
<v-divider />
|
||||||
/>
|
<v-list-item
|
||||||
<v-list-item
|
prepend-icon="mdi-delete-outline" title="Delete part"
|
||||||
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
|
base-color="error"
|
||||||
:disabled="ci === 0"
|
@click="confirmDelete(ch)"
|
||||||
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
|
/>
|
||||||
/>
|
</KebabMenu>
|
||||||
<v-divider />
|
|
||||||
<v-list-item
|
|
||||||
prepend-icon="mdi-delete-outline" title="Delete part"
|
|
||||||
base-color="error"
|
|
||||||
@click="confirmDelete(ch)"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="fc-part__statedrow">
|
<div class="fc-part__statedrow">
|
||||||
@@ -230,6 +222,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||||
|
import KebabMenu from '../components/common/KebabMenu.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useSeriesManageStore()
|
const store = useSeriesManageStore()
|
||||||
|
|||||||
@@ -60,27 +60,20 @@
|
|||||||
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
||||||
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
||||||
</span>
|
</span>
|
||||||
<v-menu>
|
<KebabMenu
|
||||||
<template #activator="{ props: menuProps }">
|
class="fc-sbcard__kebab" variant="flat"
|
||||||
<v-btn
|
:label="`Actions for ${s.name}`"
|
||||||
v-bind="menuProps"
|
>
|
||||||
icon="mdi-dots-vertical" size="x-small" variant="flat"
|
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
|
||||||
class="fc-sbcard__kebab" :aria-label="`Actions for ${s.name}`"
|
<v-list-item-title>Rename</v-list-item-title>
|
||||||
@click.stop
|
</v-list-item>
|
||||||
/>
|
<v-list-item
|
||||||
</template>
|
prepend-icon="mdi-delete" base-color="error"
|
||||||
<v-list density="compact">
|
@click.stop="openDelete(s)"
|
||||||
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
|
>
|
||||||
<v-list-item-title>Rename</v-list-item-title>
|
<v-list-item-title>Delete</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
</KebabMenu>
|
||||||
prepend-icon="mdi-delete" base-color="error"
|
|
||||||
@click.stop="openDelete(s)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>Delete</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-sbcard__body">
|
<div class="fc-sbcard__body">
|
||||||
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
||||||
@@ -210,6 +203,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
||||||
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
||||||
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||||
|
import KebabMenu from '../components/common/KebabMenu.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useSeriesBrowseStore()
|
const store = useSeriesBrowseStore()
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import KebabMenu from '../../src/components/common/KebabMenu.vue'
|
||||||
|
|
||||||
|
// Canonical kebab primitive (DRY pattern-consistency sweep). Vuetify components
|
||||||
|
// are left unresolved by the test harness, so we assert the wiring the consumers
|
||||||
|
// rely on: the trigger's aria-label, the default kebab glyph, and that the
|
||||||
|
// consumer's menu items (default slot) are rendered inside the menu.
|
||||||
|
function mountKebab (props = {}) {
|
||||||
|
return mount(KebabMenu, {
|
||||||
|
props,
|
||||||
|
slots: {
|
||||||
|
default: '<v-list-item>Rename</v-list-item><v-list-item>Delete</v-list-item>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KebabMenu', () => {
|
||||||
|
it('renders the trigger with the provided aria-label and the slotted items', () => {
|
||||||
|
const w = mountKebab({ label: 'Actions for Foo' })
|
||||||
|
expect(w.text()).toContain('Rename')
|
||||||
|
expect(w.text()).toContain('Delete')
|
||||||
|
expect(w.html()).toContain('Actions for Foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults the trigger glyph to the kebab icon', () => {
|
||||||
|
expect(mountKebab().html()).toContain('mdi-dots-vertical')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes presentational props through to the trigger/menu', () => {
|
||||||
|
const w = mountKebab({ size: 'small', variant: 'flat', location: 'bottom end' })
|
||||||
|
const html = w.html()
|
||||||
|
expect(html).toContain('small')
|
||||||
|
expect(html).toContain('flat')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user