Compare commits

...

13 Commits

Author SHA1 Message Date
Jin Yi
5591a11c2f fix: use shared MENU_HEIGHT/MENU_WIDTH constants in FormDropdownMenu
Use the shared constants from types.ts instead of hardcoded Tailwind
classes so dimension changes are reflected in both the menu component
and the positioning logic in FormDropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:11:18 +09:00
Jin Yi
b09778e879 fix: clamp teleported dropdown position to viewport bounds
When neither upward nor downward direction has enough space for the
full menu height, clamp the position so the menu stays within the
viewport instead of overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
3e587c7758 fix: prevent teleported dropdown from overflowing viewport top
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d64-af34-7489-abd5-cde23ead7105
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0d21b3aee6 fix: extract MENU_HEIGHT/MENU_WIDTH as shared constants, drop computed for shouldTeleport
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d37-f1a3-7421-90b9-b4d8d058bedb
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:06:31 +09:00
Jin Yi
8ecbbfcd48 fix: flip teleported dropdown upward when near viewport bottom
Apply the same openUpward logic for both teleported and local cases.
When teleported, use bottom CSS property to open upward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
bcbeaa8c91 fix: teleport FormDropdown to body in app mode with bottom-right positioning
Inject OverlayAppendToKey to detect app mode vs canvas. In app mode,
use Teleport to body with position:fixed at the trigger's bottom-right
corner, clamped to viewport bounds. In canvas, keep local absolute
positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
77ee629d05 Revert "fix: restore teleport for FormDropdown in app mode"
This reverts commit 8a88e40c40.
2026-04-07 11:06:31 +09:00
Jin Yi
cc8dd9a981 fix: restore teleport for FormDropdown in app mode
Inject OverlayAppendToKey to detect app mode ('body') vs canvas
('self'). In app mode, use <Teleport to="body"> with position:fixed
to escape overflow-hidden/overflow-y-auto ancestors. In canvas, keep
local absolute positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0bb9daeb2c fix: prefer direction with more available space for dropdown
Compare space above vs below the trigger and open toward whichever
side has more room. Prevents flipping upward when the menu would
overflow even more in that direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
be20d5f563 fix: flip dropdown upward when near viewport bottom
Use getBoundingClientRect() only for direction detection (not
positioning), so it works safely even inside CSS transform chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0720125c77 fix: stabilize E2E tests for FormDropdown positioning
- Replace fragile CSS selectors with data-testid for trigger button
- Update appModeDropdownClipping to use getByTestId after Popover removal
- Change zoom test from 0.5 to 0.75 to avoid too-small click targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0c3045ca5a test: add Playwright tests for FormDropdown positioning
Amp-Thread-ID: https://ampcode.com/threads/T-019d2285-317d-75db-b838-15f7d9b55b3c
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:05:27 +09:00
Jin Yi
8044bf39b7 fix: formdropdown position 2026-04-07 11:05:27 +09:00
8 changed files with 244 additions and 72 deletions

View File

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

View File

@@ -4,6 +4,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
@@ -143,12 +144,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 })
const menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
.first()
await expect(menu).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const isInViewport = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -159,7 +160,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -0,0 +1,116 @@
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.getByTestId(TestIds.widgets.formDropdownTrigger)
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.75, 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.getByTestId(TestIds.widgets.formDropdownTrigger)
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.getByTestId(TestIds.widgets.formDropdownTrigger)
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.getByTestId(TestIds.widgets.formDropdownTrigger)
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

@@ -41,11 +41,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,
@@ -65,13 +60,17 @@ function mountDropdown(
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
async function openDropdown(wrapper: ReturnType<typeof mountDropdown>) {
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
@@ -87,7 +86,7 @@ describe('FormDropdown', () => {
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(2)
@@ -106,7 +105,7 @@ describe('FormDropdown', () => {
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await openDropdown(wrapper)
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
@@ -116,7 +115,7 @@ describe('FormDropdown', () => {
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(0)
@@ -154,7 +153,10 @@ describe('FormDropdown', () => {
await flushPromises()
expect(searcher).not.toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
})
it('runs filtering when dropdown opens', async () => {
@@ -169,8 +171,7 @@ describe('FormDropdown', () => {
)
await flushPromises()
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])

View File

@@ -1,11 +1,12 @@
<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 type { CSSProperties } from 'vue'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
@@ -16,6 +17,7 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -51,7 +53,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -95,8 +96,10 @@ 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 dropdownRef = useTemplateRef('dropdownRef')
const shouldTeleport = inject(OverlayAppendToKey, undefined) === 'body'
const maxSelectable = computed(() => {
if (multiple === true) return Infinity
@@ -142,18 +145,59 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
const toggleDropdown = (event: Event) => {
const MENU_HEIGHT_WITH_GAP = MENU_HEIGHT + 8
const openUpward = ref(false)
const fixedPosition = ref({ top: 0, left: 0 })
const teleportStyle = computed<CSSProperties | undefined>(() => {
if (!shouldTeleport) return undefined
const pos = fixedPosition.value
return openUpward.value
? {
position: 'fixed',
left: `${pos.left}px`,
bottom: `${window.innerHeight - pos.top}px`,
paddingBottom: '0.5rem'
}
: {
position: 'fixed',
left: `${pos.left}px`,
top: `${pos.top}px`,
paddingTop: '0.5rem'
}
})
function toggleDropdown() {
if (disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
isOpen.value = !isOpen.value
if (!isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openUpward.value =
spaceBelow < MENU_HEIGHT_WITH_GAP && spaceAbove > spaceBelow
if (shouldTeleport) {
fixedPosition.value = {
top: openUpward.value
? Math.max(MENU_HEIGHT_WITH_GAP, rect.top)
: Math.min(rect.bottom, window.innerHeight - MENU_HEIGHT_WITH_GAP),
left: Math.min(rect.right, window.innerWidth - MENU_WIDTH)
}
}
}
isOpen.value = !isOpen.value
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
function closeDropdown() {
isOpen.value = false
}
onClickOutside(triggerRef, closeDropdown, { ignore: [dropdownRef] })
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown()
}
}
@@ -192,7 +236,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
</script>
<template>
<div ref="triggerRef">
<div ref="triggerRef" class="relative" @keydown="handleEscape">
<FormDropdownInput
:files
:is-open
@@ -207,42 +251,41 @@ 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"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
<Teleport to="body" :disabled="!shouldTeleport">
<div
v-if="isOpen"
ref="dropdownRef"
:class="
cn(
'z-50 rounded-lg border-none bg-transparent p-0 shadow-lg',
!shouldTeleport && 'absolute left-0',
!shouldTeleport &&
(openUpward ? 'bottom-full pb-2' : 'top-full pt-2')
)
"
:style="teleportStyle"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
"
>
<button
data-testid="form-dropdown-trigger"
:class="
cn(
theButtonStyle,

View File

@@ -13,6 +13,7 @@ import type {
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -97,7 +98,9 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
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-testid="form-dropdown-menu"
class="flex flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
:style="{ height: `${MENU_HEIGHT}px`, width: `${MENU_WIDTH}px` }"
data-capture-wheel="true"
>
<FormDropdownMenuFilter

View File

@@ -28,5 +28,10 @@ export interface SortOption<TId extends string = string> {
export type LayoutMode = 'list' | 'grid' | 'list-small'
/** Height of FormDropdownMenu in pixels (matches h-[640px] in template). */
export const MENU_HEIGHT = 640
/** Width of FormDropdownMenu in pixels (matches w-103 = 26rem = 416px in template). */
export const MENU_WIDTH = 412
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')