mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
5 Commits
glary/remo
...
jaewon/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e87042a4f | ||
|
|
1d6377c5d5 | ||
|
|
a280d072cf | ||
|
|
7e251d29d3 | ||
|
|
70f8c68523 |
@@ -0,0 +1,60 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { withInputFiles } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
test.describe(
|
||||
'LoadImage form dropdown reopen (FE-535)',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
test('items remain visible after scroll → close → reopen', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withInputFiles(60))
|
||||
await assetApi.mock()
|
||||
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
const loadImage = await comfyPage.nodeOps.addNode('LoadImage')
|
||||
const loadImageId = String(loadImage.id)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[loadImageId, 'image']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
const imageRow = comfyPage.appMode.widgets.getWidgetItem(
|
||||
`${loadImageId}:image`
|
||||
)
|
||||
const dropdownButton = imageRow.getByTestId('form-dropdown-trigger')
|
||||
|
||||
await dropdownButton.click()
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const scrollContainer = popover.getByTestId('form-dropdown-list')
|
||||
await expect
|
||||
.poll(() => scrollContainer.locator('img').count())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await scrollContainer.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(popover).toBeHidden()
|
||||
|
||||
await dropdownButton.click()
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => popover.locator('img').count(), { timeout: 5000 })
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import VirtualGrid from './VirtualGrid.vue'
|
||||
|
||||
@@ -9,21 +9,53 @@ type TestItem = { key: string; name: string }
|
||||
|
||||
let mockedWidth: Ref<number>
|
||||
let mockedHeight: Ref<number>
|
||||
let mockedScrollY: Ref<number>
|
||||
let mockedFirstRow: Ref<number>
|
||||
let mockedLastRow: Ref<number>
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
|
||||
useScroll: () => ({ y: mockedScrollY })
|
||||
useElementSize: () => ({ width: mockedWidth, height: mockedHeight })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/vue-virtual', () => ({
|
||||
useVirtualizer: (options: {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
}) =>
|
||||
computed(() => {
|
||||
const count = options.count
|
||||
const size = count > 0 ? options.estimateSize(0) : 0
|
||||
const first = Math.max(0, Math.min(count - 1, mockedFirstRow.value))
|
||||
const last = Math.max(first, Math.min(count - 1, mockedLastRow.value))
|
||||
const items =
|
||||
count === 0
|
||||
? []
|
||||
: Array.from({ length: last - first + 1 }, (_, i) => {
|
||||
const index = first + i
|
||||
return {
|
||||
index,
|
||||
key: index,
|
||||
start: index * size,
|
||||
end: (index + 1) * size,
|
||||
size
|
||||
}
|
||||
})
|
||||
return {
|
||||
getVirtualItems: () => items,
|
||||
getTotalSize: () => count * size,
|
||||
measure: () => {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mockedWidth = ref(400)
|
||||
mockedHeight = ref(200)
|
||||
mockedScrollY = ref(0)
|
||||
mockedFirstRow = ref(0)
|
||||
mockedLastRow = ref(0)
|
||||
})
|
||||
|
||||
function createItems(count: number): TestItem[] {
|
||||
@@ -42,9 +74,8 @@ describe('VirtualGrid', () => {
|
||||
|
||||
it('renders items within the visible range', async () => {
|
||||
const items = createItems(100)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 2
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
@@ -73,9 +104,8 @@ describe('VirtualGrid', () => {
|
||||
it('provides correct index in slot props', async () => {
|
||||
const items = createItems(20)
|
||||
const receivedIndices: number[] = []
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 4
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
@@ -106,9 +136,8 @@ describe('VirtualGrid', () => {
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
const items = createItems(10)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 1
|
||||
|
||||
const { container } = render(VirtualGrid, {
|
||||
props: {
|
||||
@@ -150,11 +179,13 @@ describe('VirtualGrid', () => {
|
||||
expect(renderedItems.length).toBe(0)
|
||||
})
|
||||
|
||||
it('emits approach-end for single-column list when scrolled near bottom', async () => {
|
||||
it('emits approach-end when last visible row reaches within bufferRows of end', async () => {
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
// start far from end so whenever() can observe the false→true transition
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 5
|
||||
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
@@ -180,42 +211,30 @@ describe('VirtualGrid', () => {
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
|
||||
// Scroll near the end: 50 items * 48px = 2400px total
|
||||
// viewRows = ceil(600/48) = 13, buffer = 1
|
||||
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
|
||||
// toCol = (offsetRows + bufferRows + viewRows) * cols
|
||||
// offsetRows = floor(scrollY / 48)
|
||||
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
|
||||
// scrollY = 35 * 48 = 1680
|
||||
mockedScrollY.value = 1680
|
||||
// 50 rows total, bufferRows=1 → fires when lastRow >= 48
|
||||
mockedFirstRow.value = 35
|
||||
mockedLastRow.value = 48
|
||||
await nextTick()
|
||||
|
||||
expect(onApproachEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit approach-end without maxColumns in single-column layout', async () => {
|
||||
// Demonstrates the bug: without maxColumns=1, cols is calculated
|
||||
// from width/itemWidth (400/200 = 2), causing incorrect row math
|
||||
it('does not emit approach-end while window is far from end', async () => {
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 5
|
||||
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
// No maxColumns — cols will be floor(400/200) = 2
|
||||
maxColumns: 1,
|
||||
bufferRows: 1,
|
||||
onApproachEnd
|
||||
},
|
||||
@@ -229,20 +248,168 @@ describe('VirtualGrid', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Same scroll position as the passing test
|
||||
mockedScrollY.value = 1680
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('derives cols from container width when maxColumns is not set', async () => {
|
||||
// width=400, defaultItemWidth=200 → cols = floor(400/200) = 2
|
||||
mockedWidth.value = 400
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 2
|
||||
|
||||
const items = createItems(20)
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 100,
|
||||
defaultItemWidth: 200,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
|
||||
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
|
||||
// The approach-end never fires at the correct scroll position
|
||||
// 3 rows rendered (firstRow=0..lastRow=2) * 2 cols = 6 items
|
||||
const rendered = screen.getAllByText(/^Item \d+$/)
|
||||
expect(rendered.length).toBe(6)
|
||||
})
|
||||
|
||||
it('emits approach-end only once per false→true transition', async () => {
|
||||
const items = createItems(50)
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 5
|
||||
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 1,
|
||||
bufferRows: 1,
|
||||
onApproachEnd
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(onApproachEnd).toHaveBeenCalledTimes(0)
|
||||
|
||||
// false → true transition: should fire exactly once
|
||||
mockedFirstRow.value = 35
|
||||
mockedLastRow.value = 48
|
||||
await nextTick()
|
||||
expect(onApproachEnd).toHaveBeenCalledTimes(1)
|
||||
|
||||
// still near-end (recompute but no transition): must not re-fire
|
||||
mockedFirstRow.value = 36
|
||||
mockedLastRow.value = 49
|
||||
await nextTick()
|
||||
expect(onApproachEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not emit approach-end during the width=0 mount transient', async () => {
|
||||
// Regression: with width=0 (pre-mount), cols falls back to 1 and a
|
||||
// small list looks near-end. Without the isValidGrid guard, isNearEnd
|
||||
// flips false→true→false during mount and consumers that increment a
|
||||
// page number (ManagerDialog) double-load and dedupe-fail.
|
||||
mockedWidth.value = 0
|
||||
mockedHeight.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 2
|
||||
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items: createItems(3),
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 200,
|
||||
defaultItemWidth: 200,
|
||||
bufferRows: 4,
|
||||
onApproachEnd
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
|
||||
// Container measures real width — list is small, all items fit, so
|
||||
// isNearEnd stays false. A spurious mount-time fire would surface here.
|
||||
mockedWidth.value = 800
|
||||
mockedHeight.value = 600
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 0
|
||||
await nextTick()
|
||||
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders correctly after items shrink below previous render window (FE-535)', async () => {
|
||||
// Simulate "scrolled deep, then items shrink" scenario
|
||||
mockedFirstRow.value = 28
|
||||
mockedLastRow.value = 32
|
||||
|
||||
const { rerender } = render(VirtualGrid, {
|
||||
props: {
|
||||
items: createItems(100),
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(screen.queryAllByText(/^Item \d+$/).length).toBeGreaterThan(0)
|
||||
|
||||
// Items shrink. Browser auto-clamps scrollTop, virtualizer reports
|
||||
// valid rows for the new (smaller) count.
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 4
|
||||
await rerender({
|
||||
items: createItems(5),
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryAllByText(/^Item \d+$/).length).toBe(5)
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
mockedFirstRow.value = 0
|
||||
mockedLastRow.value = 2
|
||||
|
||||
const items = createItems(20)
|
||||
render(VirtualGrid, {
|
||||
|
||||
@@ -18,22 +18,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useElementSize, useScroll, whenever } from '@vueuse/core'
|
||||
import { clamp, debounce } from 'es-toolkit/compat'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useElementSize, whenever } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
type GridState = {
|
||||
start: number
|
||||
end: number
|
||||
isNearEnd: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200,
|
||||
@@ -42,7 +36,6 @@ const {
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
@@ -51,7 +44,8 @@ const {
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* Emitted when `bufferRows` (or fewer) rows remaining between scrollY and grid bottom.
|
||||
* Edge-triggered when the rendered window reaches within `bufferRows`
|
||||
* rows of the grid's last item.
|
||||
*/
|
||||
'approach-end': []
|
||||
}>()
|
||||
@@ -60,10 +54,14 @@ const itemHeight = ref(defaultItemHeight)
|
||||
const itemWidth = ref(defaultItemWidth)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { width, height } = useElementSize(container)
|
||||
const { y: scrollY } = useScroll(container, {
|
||||
throttle: scrollThrottle,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
// Suppress range computation while the container is unmounted/zero-sized.
|
||||
// Without this, cols collapses to 1 during the brief width=0 mount window,
|
||||
// which makes a small list look near-end and emits a spurious approach-end
|
||||
// that double-loads paginated consumers (ManagerDialog).
|
||||
const isValidGrid = computed(
|
||||
() => width.value > 0 && height.value > 0 && items.length > 0
|
||||
)
|
||||
|
||||
const cols = computed(() => {
|
||||
if (maxColumns !== Infinity) return maxColumns
|
||||
@@ -78,39 +76,57 @@ const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||
const rowCount = computed(() => Math.ceil(items.length / cols.value))
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
get count() {
|
||||
return rowCount.value
|
||||
},
|
||||
estimateSize: () => itemHeight.value,
|
||||
getScrollElement: () => container.value,
|
||||
overscan: bufferRows
|
||||
})
|
||||
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize())
|
||||
|
||||
type GridState = {
|
||||
start: number
|
||||
end: number
|
||||
isNearEnd: boolean
|
||||
}
|
||||
|
||||
const state = computed<GridState>(() => {
|
||||
const fromRow = offsetRows.value - bufferRows
|
||||
const toRow = offsetRows.value + bufferRows + viewRows.value
|
||||
const rows = virtualRows.value
|
||||
if (!isValidGrid.value || rows.length === 0) {
|
||||
return { start: 0, end: 0, isNearEnd: false }
|
||||
}
|
||||
const firstRow = rows[0]
|
||||
const lastRow = rows[rows.length - 1]
|
||||
const start = firstRow.index * cols.value
|
||||
const end = Math.min(items.length, (lastRow.index + 1) * cols.value)
|
||||
|
||||
const fromCol = fromRow * cols.value
|
||||
const toCol = toRow * cols.value
|
||||
const toCol = (lastRow.index + 1) * cols.value
|
||||
const remainingCol = items.length - toCol
|
||||
const hasMoreToRender = remainingCol >= 0
|
||||
const isNearEnd = hasMoreToRender && remainingCol <= cols.value * bufferRows
|
||||
|
||||
return {
|
||||
start: clamp(fromCol, 0, items?.length),
|
||||
end: clamp(toCol, fromCol, items?.length),
|
||||
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
|
||||
}
|
||||
return { start, end, isNearEnd }
|
||||
})
|
||||
|
||||
const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(itemsCount: number): string {
|
||||
const rows = Math.ceil(itemsCount / cols.value)
|
||||
return `${rows * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(items.length - state.value.end)
|
||||
height: `${virtualRows.value[0]?.start ?? 0}px`
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => {
|
||||
const rows = virtualRows.value
|
||||
if (rows.length === 0) return { height: '0px' }
|
||||
const lastEnd = rows[rows.length - 1].end
|
||||
return { height: `${Math.max(0, totalSize.value - lastEnd)}px` }
|
||||
})
|
||||
|
||||
whenever(
|
||||
() => state.value.isNearEnd,
|
||||
@@ -120,22 +136,24 @@ whenever(
|
||||
)
|
||||
|
||||
function updateItemSize(): void {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
if (!container.value) return
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
}
|
||||
const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
|
||||
watch(width, onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
watch(itemHeight, () => {
|
||||
virtualizer.value.measure()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel()
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
|
||||
}
|
||||
)
|
||||
"
|
||||
data-testid="form-dropdown-trigger"
|
||||
@click="emit('select-click', $event)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-2 text-left">
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('FormDropdownMenu', () => {
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('virtual-grid')).toBeTruthy()
|
||||
expect(screen.getByTestId('form-dropdown-list')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('transforms items to include key property for VirtualGrid', () => {
|
||||
@@ -72,7 +72,7 @@ describe('FormDropdownMenu', () => {
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const virtualGrid = screen.getByTestId('virtual-grid')
|
||||
const virtualGrid = screen.getByTestId('form-dropdown-list')
|
||||
const virtualItems = JSON.parse(virtualGrid.getAttribute('data-items')!)
|
||||
|
||||
expect(virtualItems).toHaveLength(2)
|
||||
@@ -89,7 +89,7 @@ describe('FormDropdownMenu', () => {
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const virtualGrid = screen.getByTestId('virtual-grid')
|
||||
const virtualGrid = screen.getByTestId('form-dropdown-list')
|
||||
expect(virtualGrid.getAttribute('data-max-columns')).toBe('1')
|
||||
})
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ const gridStyle = computed<CSSProperties>(() => ({
|
||||
display: 'grid',
|
||||
gap: layoutConfig.value.gap,
|
||||
padding: '1rem',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
gridAutoRows: `${layoutConfig.value.itemHeight}px`
|
||||
}))
|
||||
|
||||
type VirtualDropdownItem = FormDropdownItem & { key: string }
|
||||
@@ -99,6 +100,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
<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-capture-wheel="true"
|
||||
data-testid="form-dropdown-menu"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
@@ -137,6 +139,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
data-testid="form-dropdown-list"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<FormDropdownMenuItem
|
||||
|
||||
Reference in New Issue
Block a user