Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
53e37c7e28 fix: dismiss queue history menus on pointerdown 2026-03-11 11:46:06 -07:00
5 changed files with 340 additions and 33 deletions

View File

@@ -1,9 +1,54 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const popoverStub = defineComponent({
name: 'Popover',
emits: ['show', 'hide'],
data() {
return {
visible: false,
container: null as HTMLElement | null,
eventTarget: null as EventTarget | null,
target: null as EventTarget | null
}
},
mounted() {
this.container = this.$refs.container as HTMLElement | null
},
updated() {
this.container = this.$refs.container as HTMLElement | null
},
methods: {
toggle(event: Event, target?: EventTarget | null) {
if (this.visible) {
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
this.visible = true
this.eventTarget = event.currentTarget
this.target = target ?? event.currentTarget
this.$emit('show')
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
})
const buttonStub = {
props: {
disabled: {
@@ -37,31 +82,57 @@ const mountComponent = (entries: MenuEntry[]) =>
props: { entries },
global: {
stubs: {
Popover: {
template: '<div class="popover-stub"><slot /></div>'
},
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
currentTarget,
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
@@ -73,9 +144,52 @@ describe('JobContextMenu', () => {
onClick: vi.fn()
}
])
await openMenu(wrapper)
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -12,8 +12,11 @@
]
}
}"
@show="isVisible = true"
@hide="onHide"
>
<div
ref="contentRef"
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
@@ -45,9 +48,10 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
@@ -56,18 +60,55 @@ const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
const jobItemPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
type PopoverHandle = {
hide: () => void
show: (event: Event, target?: EventTarget | null) => void
}
function open(event: Event) {
if (jobItemPopoverRef.value) {
jobItemPopoverRef.value.toggle(event)
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isVisible = ref(false)
const openedByClick = ref(false)
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => contentRef.value,
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
onDismiss: hide
})
async function open(event: Event) {
const trigger =
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const isSameClickTrigger =
event.type === 'click' && trigger === triggerRef.value && isVisible.value
if (isSameClickTrigger) {
hide()
return
}
openedByClick.value = event.type === 'click'
triggerRef.value = trigger
if (isVisible.value) {
hide()
await nextTick()
}
jobItemPopoverRef.value?.show(event, trigger)
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onHide() {
isVisible.value = false
openedByClick.value = false
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)

View File

@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import type { EffectScope, Ref } from 'vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
describe('useDismissableOverlay', () => {
let scope: EffectScope | undefined
let isOpen: Ref<boolean>
let overlayEl: HTMLElement
let triggerEl: HTMLElement
let outsideEl: HTMLElement
let dismissCount: number
const mountComposable = ({
dismissOnScroll = false,
getTriggerEl
}: {
dismissOnScroll?: boolean
getTriggerEl?: () => HTMLElement | null
} = {}) => {
scope = effectScope()
scope.run(() =>
useDismissableOverlay({
isOpen,
getOverlayEl: () => overlayEl,
getTriggerEl,
onDismiss: () => {
dismissCount += 1
},
dismissOnScroll
})
)
}
beforeEach(() => {
isOpen = ref(true)
overlayEl = document.createElement('div')
triggerEl = document.createElement('button')
outsideEl = document.createElement('div')
dismissCount = 0
document.body.append(overlayEl, triggerEl, outsideEl)
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('dismisses on outside pointerdown', () => {
mountComposable()
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(1)
})
it('ignores pointerdown inside the overlay', () => {
mountComposable()
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('ignores pointerdown inside the trigger', () => {
mountComposable({
getTriggerEl: () => triggerEl
})
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('dismisses on scroll when enabled', () => {
mountComposable({
dismissOnScroll: true
})
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(1)
})
it('does not dismiss when closed', () => {
isOpen.value = false
mountComposable({
dismissOnScroll: true
})
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
})

View File

@@ -0,0 +1,66 @@
import { useEventListener } from '@vueuse/core'
import { toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface UseDismissableOverlayOptions {
isOpen: MaybeRefOrGetter<boolean>
getOverlayEl: () => HTMLElement | null
onDismiss: () => void
getTriggerEl?: () => HTMLElement | null
dismissOnScroll?: boolean
}
const isNode = (value: EventTarget | null | undefined): value is Node =>
value instanceof Node
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
!!element?.contains(target)
export function useDismissableOverlay({
isOpen,
getOverlayEl,
onDismiss,
getTriggerEl,
dismissOnScroll = false
}: UseDismissableOverlayOptions) {
const dismissIfOutside = (event: PointerEvent) => {
if (!toValue(isOpen) || !isNode(event.target)) {
return
}
const overlay = getOverlayEl()
if (!overlay) {
return
}
if (
isInside(event.target, overlay) ||
isInside(event.target, getTriggerEl?.())
) {
return
}
onDismiss()
}
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
if (dismissOnScroll) {
useEventListener(
window,
'scroll',
() => {
if (!toValue(isOpen)) {
return
}
onDismiss()
},
{
capture: true,
passive: true
}
)
}
}

View File

@@ -4,6 +4,7 @@
:model="contextMenuItems"
:pt="{
root: {
id: contextMenuId,
class: cn(
'rounded-lg',
'bg-secondary-background text-base-foreground',
@@ -29,14 +30,13 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
@@ -74,34 +74,22 @@ const emit = defineEmits<{
'bulk-export-workflow': [assets: AssetItem[]]
}>()
type ContextMenuInstance = ComponentPublicInstance & {
type ContextMenuHandle = {
show: (event: MouseEvent) => void
hide: () => void
container?: HTMLElement
$el?: HTMLElement
}
const contextMenu = ref<ContextMenuInstance | null>(null)
const contextMenu = ref<ContextMenuHandle | null>(null)
const contextMenuId = useId()
const isVisible = ref(false)
const actions = useMediaAssetActions()
const { t } = useI18n()
function getOverlayEl(): HTMLElement | null {
return contextMenu.value?.container ?? contextMenu.value?.$el ?? null
}
function dismissIfOutside(event: Event) {
if (!isVisible.value) return
const overlay = getOverlayEl()
if (!overlay) return
if (overlay.contains(event.target as Node)) return
hide()
}
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
useEventListener(window, 'scroll', dismissIfOutside, {
capture: true,
passive: true
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => document.getElementById(contextMenuId),
onDismiss: hide,
dismissOnScroll: true
})
const showAddToWorkflow = computed(() => {