mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
7 Commits
codex/clou
...
fix/1.42-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaa49381d | ||
|
|
1a6ecd2304 | ||
|
|
cedeb468eb | ||
|
|
ef4386692f | ||
|
|
48ea044927 | ||
|
|
c5acfb0fd7 | ||
|
|
78daab33f0 |
@@ -23,7 +23,7 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
/** The Reka UI Popover for the image picker (teleported to body). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
@@ -54,10 +54,7 @@ export class AppModeHelper {
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.locator('[data-testid="linear-widgets"]')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.imagePickerPopover = this.page.getByTestId('form-dropdown-content')
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
|
||||
@@ -137,27 +137,20 @@ 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").
|
||||
// The Reka UI PopoverContent is teleported to <body> via PopoverPortal,
|
||||
// so it's never clipped by the app mode panel's overflow container.
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
// Verify popover is outside the linear-widgets container
|
||||
// (PopoverPortal teleports it to <body>, escaping overflow: hidden)
|
||||
await expect
|
||||
.poll(() =>
|
||||
popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
const panel = document.querySelector('[data-testid="linear-widgets"]')
|
||||
return panel ? !panel.contains(el) : true
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(() => popover.evaluate(isClippedByAnyAncestor))
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('FormDropdown Position Under CSS Transforms', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getTriggerButton(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
return node
|
||||
.locator('button')
|
||||
.filter({
|
||||
has: comfyPage.page.locator('.icon-\\[lucide--chevron-down\\]')
|
||||
})
|
||||
.and(comfyPage.page.locator(':not([data-testid="node-collapse-button"])'))
|
||||
}
|
||||
|
||||
async function clickDropdownTrigger(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
await getTriggerButton(comfyPage).click()
|
||||
}
|
||||
|
||||
function getDropdownContent(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return comfyPage.page.getByTestId('form-dropdown-content')
|
||||
}
|
||||
|
||||
test('dropdown menu appears near trigger at default zoom', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await clickDropdownTrigger(comfyPage)
|
||||
|
||||
const menu = getDropdownContent(comfyPage)
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const trigger = getTriggerButton(comfyPage)
|
||||
const triggerBox = await trigger.boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
|
||||
expect(triggerBox).not.toBeNull()
|
||||
expect(menuBox).not.toBeNull()
|
||||
|
||||
// Menu should appear below the trigger, within a reasonable gap
|
||||
// (side-offset is 8px, plus some tolerance for rounding)
|
||||
const gap = menuBox!.y - (triggerBox!.y + triggerBox!.height)
|
||||
expect(gap).toBeGreaterThanOrEqual(-2)
|
||||
expect(gap).toBeLessThanOrEqual(20)
|
||||
|
||||
// Menu should be horizontally aligned with the trigger
|
||||
const horizontalDrift = Math.abs(menuBox!.x - triggerBox!.x)
|
||||
expect(horizontalDrift).toBeLessThan(50)
|
||||
})
|
||||
|
||||
test('dropdown closes on Escape key', async ({ comfyPage }) => {
|
||||
await clickDropdownTrigger(comfyPage)
|
||||
|
||||
const menu = getDropdownContent(comfyPage)
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
846
pnpm-lock.yaml
generated
846
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import { capitalize } from 'es-toolkit'
|
||||
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
@@ -75,12 +74,9 @@ const toastStore = useToastStore()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
const combinedProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const getAssetData = () => {
|
||||
const nodeType: string | undefined =
|
||||
|
||||
@@ -40,12 +40,7 @@ const MockFormDropdownMenu = {
|
||||
const MockFormDropdownInput = {
|
||||
name: 'FormDropdownInput',
|
||||
template:
|
||||
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
|
||||
}
|
||||
|
||||
const MockPopover = {
|
||||
name: 'Popover',
|
||||
template: '<div><slot /></div>'
|
||||
'<button data-testid="mock-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
|
||||
}
|
||||
|
||||
interface MountDropdownOptions {
|
||||
@@ -55,6 +50,7 @@ interface MountDropdownOptions {
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<FormDropdownItem[]>
|
||||
searchQuery?: string
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
function flushPromises() {
|
||||
@@ -72,8 +68,8 @@ function mountDropdown(
|
||||
plugins: [PrimeVue, i18n],
|
||||
stubs: {
|
||||
FormDropdownInput: MockFormDropdownInput,
|
||||
Popover: MockPopover,
|
||||
FormDropdownMenu: MockFormDropdownMenu
|
||||
FormDropdownMenu: MockFormDropdownMenu,
|
||||
PopoverPortal: { template: '<slot />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -85,18 +81,50 @@ function getMenuItems(): FormDropdownItem[] {
|
||||
return JSON.parse(menuEl.getAttribute('data-items') ?? '[]')
|
||||
}
|
||||
|
||||
async function openDropdown(
|
||||
result: ReturnType<typeof mountDropdown>
|
||||
): Promise<void> {
|
||||
await result.user.click(screen.getByTestId('mock-trigger'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FormDropdown', () => {
|
||||
describe('open/close behavior', () => {
|
||||
it('opens the dropdown menu when trigger is clicked', async () => {
|
||||
const result = mountDropdown([createItem('1', 'item1')])
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.queryByTestId('dropdown-menu')).toBeNull()
|
||||
|
||||
await openDropdown(result)
|
||||
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dropdown when trigger is clicked again', async () => {
|
||||
const result = mountDropdown([createItem('1', 'item1')])
|
||||
await flushPromises()
|
||||
|
||||
await openDropdown(result)
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
|
||||
await openDropdown(result)
|
||||
expect(screen.queryByTestId('dropdown-menu')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('filteredItems updates when items prop changes', () => {
|
||||
it('updates displayed items when items prop changes', async () => {
|
||||
const { rerender } = mountDropdown([
|
||||
const result = mountDropdown([
|
||||
createItem('input-0', 'video1.mp4'),
|
||||
createItem('input-1', 'video2.mp4')
|
||||
])
|
||||
await flushPromises()
|
||||
await openDropdown(result)
|
||||
|
||||
expect(getMenuItems()).toHaveLength(2)
|
||||
|
||||
await rerender({
|
||||
await result.rerender({
|
||||
items: [
|
||||
createItem('output-0', 'rendered1.mp4'),
|
||||
createItem('output-1', 'rendered2.mp4')
|
||||
@@ -110,28 +138,30 @@ describe('FormDropdown', () => {
|
||||
})
|
||||
|
||||
it('updates when items change but IDs stay the same', async () => {
|
||||
const { rerender } = mountDropdown([createItem('1', 'alpha')])
|
||||
const result = mountDropdown([createItem('1', 'alpha')])
|
||||
await flushPromises()
|
||||
await openDropdown(result)
|
||||
|
||||
await rerender({ items: [createItem('1', 'beta')] })
|
||||
await result.rerender({ items: [createItem('1', 'beta')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems()[0].name).toBe('beta')
|
||||
})
|
||||
|
||||
it('updates when switching between empty and non-empty items', async () => {
|
||||
const { rerender } = mountDropdown([])
|
||||
const result = mountDropdown([], { isOpen: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems()).toHaveLength(0)
|
||||
|
||||
await rerender({ items: [createItem('1', 'video.mp4')] })
|
||||
await result.rerender({
|
||||
items: [createItem('1', 'video.mp4')],
|
||||
isOpen: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems()).toHaveLength(1)
|
||||
expect(getMenuItems()[0].name).toBe('video.mp4')
|
||||
|
||||
await rerender({ items: [] })
|
||||
await result.rerender({ items: [], isOpen: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems()).toHaveLength(0)
|
||||
@@ -144,7 +174,7 @@ describe('FormDropdown', () => {
|
||||
sourceItems.filter((item) => item.name.includes('video'))
|
||||
)
|
||||
|
||||
const { rerender } = mountDropdown(
|
||||
const result = mountDropdown(
|
||||
[createItem('1', 'video-a.mp4'), createItem('2', 'video-b.mp4')],
|
||||
{ searcher }
|
||||
)
|
||||
@@ -152,12 +182,12 @@ describe('FormDropdown', () => {
|
||||
|
||||
expect(searcher).not.toHaveBeenCalled()
|
||||
|
||||
await rerender({
|
||||
await result.rerender({
|
||||
items: [createItem('1', 'video-a.mp4'), createItem('2', 'video-b.mp4')],
|
||||
searcher,
|
||||
searchQuery: 'video-a'
|
||||
})
|
||||
await rerender({
|
||||
await result.rerender({
|
||||
items: [createItem('3', 'video-c.mp4'), createItem('4', 'video-d.mp4')],
|
||||
searcher,
|
||||
searchQuery: 'video-a'
|
||||
@@ -165,7 +195,6 @@ describe('FormDropdown', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(searcher).not.toHaveBeenCalled()
|
||||
expect(getMenuItems().map((item) => item.id)).toEqual(['3', '4'])
|
||||
})
|
||||
|
||||
it('runs filtering when dropdown opens', async () => {
|
||||
@@ -174,15 +203,13 @@ describe('FormDropdown', () => {
|
||||
sourceItems.filter((item) => item.id === 'keep')
|
||||
)
|
||||
|
||||
const { container, user } = mountDropdown(
|
||||
const result = mountDropdown(
|
||||
[createItem('keep', 'alpha'), createItem('drop', 'beta')],
|
||||
{ searcher }
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
await user.click(container.querySelector('.mock-dropdown-trigger')!)
|
||||
await flushPromises()
|
||||
await openDropdown(result)
|
||||
|
||||
expect(searcher).toHaveBeenCalled()
|
||||
expect(getMenuItems().map((item) => item.id)).toEqual(['keep'])
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync, refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot
|
||||
} from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -51,7 +55,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -95,8 +98,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(() => {
|
||||
if (multiple === true) return Infinity
|
||||
@@ -142,19 +143,14 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
|
||||
return isSelected(selected.value, item, index)
|
||||
}
|
||||
|
||||
const toggleDropdown = (event: Event) => {
|
||||
function toggleDropdown(event: Event) {
|
||||
if (disabled) return
|
||||
if (popoverRef.value && triggerRef.value) {
|
||||
popoverRef.value.toggle?.(event, triggerRef.value)
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
void event
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.hide?.()
|
||||
isOpen.value = false
|
||||
}
|
||||
function closeDropdown() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
@@ -192,57 +188,56 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef">
|
||||
<FormDropdownInput
|
||||
:files
|
||||
:is-open
|
||||
:placeholder="placeholderText"
|
||||
:items
|
||||
:display-items
|
||||
:max-selectable
|
||||
:selected
|
||||
:uploadable
|
||||
:disabled
|
||||
:accept
|
||||
@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"
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverAnchor as-child>
|
||||
<FormDropdownInput
|
||||
:files
|
||||
:is-open
|
||||
:placeholder="placeholderText"
|
||||
:items
|
||||
:display-items
|
||||
:max-selectable
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
:selected
|
||||
:uploadable
|
||||
:disabled
|
||||
:accept
|
||||
@select-click="toggleDropdown"
|
||||
@file-change="handleFileChange"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
data-testid="form-dropdown-content"
|
||||
class="z-50"
|
||||
@escape-key-down="closeDropdown"
|
||||
@pointer-down-outside="closeDropdown"
|
||||
@focus-outside.prevent
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user