fix: formdropdown position

This commit is contained in:
Jin Yi
2026-03-25 10:57:12 +09:00
parent 809da9c11c
commit b495372511
5 changed files with 163 additions and 48 deletions

View File

@@ -112,7 +112,8 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu'
},
builder: {
footerNav: 'builder-footer-nav',

View File

@@ -0,0 +1,124 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe(
'FormDropdown positioning in Vue nodes',
{ tag: ['@widget', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
test('dropdown menu appears directly below the trigger', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.locator(
'button:has(> span > span), button:has(i.icon-\\[lucide--chevron-down\\])'
)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20
)
// Menu left should be near the trigger left (within 10px tolerance)
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
})
test('dropdown menu appears correctly at different zoom levels', async ({
comfyPage
}) => {
for (const zoom of [0.5, 1.5]) {
// Set zoom via canvas
await comfyPage.page.evaluate((scale) => {
const canvas = window.app!.canvas
canvas.ds.scale = scale
canvas.setDirty(true, true)
}, zoom)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.locator(
'button:has(i.icon-\\[lucide--chevron-down\\])'
)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(
TestIds.widgets.formDropdownMenu
)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should still be near trigger bottom regardless of zoom
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20 * zoom
)
// Close dropdown before next iteration
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
}
})
test('dropdown closes on outside click', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.locator(
'button:has(i.icon-\\[lucide--chevron-down\\])'
)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
// Click outside the node
await comfyPage.page.mouse.click(10, 10)
await expect(menu).not.toBeVisible()
})
test('dropdown closes on Escape key', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.locator(
'button:has(i.icon-\\[lucide--chevron-down\\])'
)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
})
}
)

View File

@@ -43,11 +43,6 @@ const MockFormDropdownInput = {
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
}
const MockPopover = {
name: 'Popover',
template: '<div><slot /></div>'
}
interface MountDropdownOptions {
searcher?: (
query: string,
@@ -72,7 +67,6 @@ function mountDropdown(
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
FormDropdownMenu: MockFormDropdownMenu
}
}
@@ -80,6 +74,15 @@ function mountDropdown(
return { ...result, user }
}
async function openDropdown(
container: HTMLElement,
user: ReturnType<typeof userEvent.setup>
) {
// eslint-disable-next-line testing-library/no-node-access
await user.click(container.querySelector('.mock-dropdown-trigger')!)
await flushPromises()
}
function getMenuItems(): FormDropdownItem[] {
const menuEl = screen.getByTestId('dropdown-menu')
return JSON.parse(menuEl.getAttribute('data-items') ?? '[]')
@@ -88,11 +91,11 @@ function getMenuItems(): FormDropdownItem[] {
describe('FormDropdown', () => {
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const { rerender } = mountDropdown([
const { rerender, container, user } = mountDropdown([
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
await openDropdown(container, user)
expect(getMenuItems()).toHaveLength(2)
@@ -110,8 +113,10 @@ describe('FormDropdown', () => {
})
it('updates when items change but IDs stay the same', async () => {
const { rerender } = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
const { rerender, container, user } = mountDropdown([
createItem('1', 'alpha')
])
await openDropdown(container, user)
await rerender({ items: [createItem('1', 'beta')] })
await flushPromises()
@@ -120,8 +125,8 @@ describe('FormDropdown', () => {
})
it('updates when switching between empty and non-empty items', async () => {
const { rerender } = mountDropdown([])
await flushPromises()
const { rerender, container, user } = mountDropdown([])
await openDropdown(container, user)
expect(getMenuItems()).toHaveLength(0)
@@ -180,9 +185,7 @@ describe('FormDropdown', () => {
)
await flushPromises()
// eslint-disable-next-line testing-library/no-node-access
await user.click(container.querySelector('.mock-dropdown-trigger')!)
await flushPromises()
await openDropdown(container, user)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems().map((item) => item.id)).toEqual(['keep'])

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { computedAsync, onClickOutside, refDebounced } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
@@ -51,7 +49,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -95,7 +92,6 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const maxSelectable = computed(() => {
@@ -142,18 +138,20 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
const toggleDropdown = (event: Event) => {
function toggleDropdown() {
if (disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
isOpen.value = !isOpen.value
}
isOpen.value = !isOpen.value
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
function closeDropdown() {
isOpen.value = false
}
onClickOutside(triggerRef, closeDropdown)
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown()
}
}
@@ -192,7 +190,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
</script>
<template>
<div ref="triggerRef">
<div ref="triggerRef" class="relative" @keydown="handleEscape">
<FormDropdownInput
:files
:is-open
@@ -207,21 +205,9 @@ function handleSelection(item: FormDropdownItem, index: number) {
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
<div
v-if="isOpen"
class="absolute top-full left-0 z-50 rounded-lg border-none bg-transparent p-0 pt-2 shadow-lg"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
@@ -243,6 +229,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
</div>
</div>
</template>

View File

@@ -97,6 +97,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
data-testid="form-dropdown-menu"
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
>