mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
## Summary Allow asset/media FormDropdown searches to select the top filtered result when the user presses Enter. This covers image, video, audio, mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed media widgets. ## Implementation Scope This PR implements a **top-result Enter shortcut** for the custom asset/media dropdown path only: - In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media widgets. - In scope: while the dropdown is open, single-select, and the search text is non-empty, the first current search result becomes the Enter candidate. - In scope: pressing Enter in the search input selects that candidate/top result through the existing selection path. - In scope: candidate feedback for this shortcut, including visual candidate styling and a polite screen-reader announcement for the current top result. - In scope: stale async search protection, empty-query/no-result no-op behavior, multi-select guard behavior, and focus return to the trigger after Enter selection closes the menu. - Out of scope: plain combo widgets (`WidgetSelectDefault` / `SelectPlus`). That path is PrimeVue-based and should be handled separately from this focused asset-widget PR. - Out of scope: full combobox/listbox keyboard navigation, including Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End behavior, scroll-to-active-item behavior, and a full ARIA combobox/listbox refactor. Follow-up arrow-key navigation should validate the interaction model separately. This PR keeps the candidate state narrow and localized so that future work can either extend it into movable active-item state or replace it as part of a fuller combobox/listbox implementation. ## Changes - **What**: Added an explicit Enter event from `FormSearchInput`, routed it through the FormDropdown menu actions, and selected the current top search result in `FormDropdown`. - **What**: Kept the existing `computedAsync` + debounced filtering path for normal typing, while Enter performs a one-off search against the latest input before selecting. Stale async Enter results are ignored if the query or item source changes before resolution. - **What**: Prevented closed FormDropdown state from treating the full unfiltered list as current search results, limited Enter-to-select to single-select dropdowns, and made empty search Enter a no-op. - **What**: Returned focus to the dropdown trigger after single-select selection closes the menu. - **What**: Added candidate styling for the first current FormDropdown result while a search query is active so the Enter target is visible to users. - **What**: Added a polite screen-reader announcement for the current top result candidate. - **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model default to use a `Set` factory instead of a shared instance. - **What**: Added unit coverage for the search Enter event, FormDropdown selection behavior, focus return, debounce/Enter behavior, stale async Enter protection, empty-query no-op behavior, closed-state stale result protection, multi-select guard behavior, and candidate announcement behavior. Added App Mode E2E coverage for asset FormDropdown Enter selection. - **What**: Extracted reusable app-mode dropdown fixture helpers and updated the existing FormDropdown clipping test to use the shared helper. ## Review Focus Please focus review on the asset/media FormDropdown path, especially `getTopSearchResult()`, the single-select/empty-query guards, stale async search protection, trigger focus return after selection, and candidate feedback in grid/list layouts. The plain combo path and full arrow-key navigation are intentionally left for separate follow-up work. ## Screenshots (if applicable) https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9 Validation performed: - Latest final-commit validation: - `pnpm test:unit src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts` - Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`, `pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache --fix ...`, `pnpm typecheck` - Push hook: `pnpm knip --cache` - `git diff --check` - Earlier branch validation for this flow: - `pnpm install` - `pnpm typecheck:browser` - `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser -- --project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and Drop|FormDropdown search Enter selects the top filtered item" --reporter=list` - `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser -- --project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown search Enter selects the top filtered item" --reporter=list` - `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser -- --project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts -g "FormDropdown popup is not clipped" --reporter=list`
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
import {
|
|
comfyPageFixture as test,
|
|
comfyExpect as expect
|
|
} from '@e2e/fixtures/ComfyPage'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
|
|
test.describe('App mode usage', () => {
|
|
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
await comfyPage.settings.setSetting(
|
|
'Comfy.NodeSearchBoxImpl',
|
|
'v1 (legacy)'
|
|
)
|
|
const { centerPanel } = comfyPage.appMode
|
|
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
|
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
|
|
|
//an app without an image input will load the workflow
|
|
await test.step('App without an image input loads workflow', async () => {
|
|
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
|
await expect(centerPanel).toBeHidden()
|
|
})
|
|
|
|
//prep a load image
|
|
await test.step('Add a load image node', async () => {
|
|
await comfyPage.workflow.loadWorkflow('default')
|
|
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
|
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
|
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
|
await expect(loadImage).toBeVisible()
|
|
})
|
|
|
|
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
|
|
|
|
await test.step('Enter app mode with image input', async () => {
|
|
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
|
await expect(centerPanel).toBeVisible()
|
|
|
|
await expect(imageInput.root).toBeVisible()
|
|
})
|
|
|
|
await test.step('Dragging an image redirects to image input', async () => {
|
|
const initialImage = await imageInput.selectedItem()
|
|
|
|
await comfyPage.dragDrop.dragAndDropExternalResource({
|
|
fileName: 'workflow.webp',
|
|
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
|
|
preserveNativePropagation: true
|
|
})
|
|
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
|
|
|
|
await expect(imageInput.selection).not.toHaveText(initialImage)
|
|
await expect(
|
|
centerPanel,
|
|
'A file with workflow should not open a new workflow'
|
|
).toBeVisible()
|
|
})
|
|
|
|
await test.step('Dragging a url redirects to image input', async () => {
|
|
const secondImage = await imageInput.selectedItem()
|
|
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
|
|
preserveNativePropagation: true
|
|
})
|
|
comfyFiles.deleteAfterTest({
|
|
filename: 'og-image.png',
|
|
type: 'input'
|
|
})
|
|
await expect(imageInput.selection).not.toHaveText(secondImage)
|
|
})
|
|
})
|
|
|
|
test('Widget Interaction', async ({ comfyPage }) => {
|
|
await comfyPage.appMode.enterAppModeWithInputs([
|
|
['3', 'seed'],
|
|
['3', 'sampler_name'],
|
|
['6', 'text']
|
|
])
|
|
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
|
exact: true
|
|
})
|
|
const { input, incrementButton, decrementButton } =
|
|
comfyPage.vueNodes.getInputNumberControls(seed)
|
|
const initialValue = Number(await input.inputValue())
|
|
|
|
await seed.dragTo(incrementButton, { steps: 5 })
|
|
const intermediateValue = Number(await input.inputValue())
|
|
expect(intermediateValue).toBeGreaterThan(initialValue)
|
|
|
|
await seed.dragTo(decrementButton, { steps: 5 })
|
|
const endValue = Number(await input.inputValue())
|
|
expect(endValue).toBeLessThan(intermediateValue)
|
|
|
|
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
|
exact: true
|
|
})
|
|
await sampler.click()
|
|
|
|
await comfyPage.page
|
|
.getByTestId(TestIds.widgets.selectDefaultSearchInput)
|
|
.fill('uni')
|
|
await comfyPage.page.keyboard.press('Enter')
|
|
await expect(sampler).toHaveText('uni_pc')
|
|
|
|
//verify values are consistent with litegraph
|
|
})
|
|
|
|
test('FormDropdown search Enter selects the top filtered item', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.appMode.enableLinearMode()
|
|
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
|
|
await comfyPage.nextFrame()
|
|
|
|
const fileComboWidget = await loadImageNode.getWidget(0)
|
|
const targetImage = String(await fileComboWidget.getValue())
|
|
const initialImage = 'not-selected.png'
|
|
await comfyPage.page.evaluate(
|
|
([nodeId, value]) => {
|
|
const node = window.app!.graph!.getNodeById(nodeId)
|
|
const widget = node?.widgets?.[0]
|
|
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
|
|
|
|
widget.value = value
|
|
},
|
|
[loadImageNode.id, initialImage] as const
|
|
)
|
|
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
|
|
|
|
await comfyPage.appMode.enterAppModeWithInputs([
|
|
[String(loadImageNode.id), 'image']
|
|
])
|
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
|
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
|
`${loadImageNode.id}:image`
|
|
)
|
|
const popover = comfyPage.appMode.imagePickerPopover
|
|
|
|
await expect(imageInput.root).toBeVisible()
|
|
await imageInput.searchAndSelectTop(popover, targetImage)
|
|
|
|
await expect(popover).toBeHidden()
|
|
await expect(imageInput.selection).toHaveText(targetImage)
|
|
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
|
})
|
|
|
|
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
|
test('panel navigation', async ({ comfyPage }) => {
|
|
const { mobile } = comfyPage.appMode
|
|
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
|
await expect(mobile.view).toBeVisible()
|
|
await expect(mobile.navigation).toBeVisible()
|
|
|
|
await mobile.navigateTab('assets')
|
|
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
|
|
|
const buttons = await mobile.navigationTabs.all()
|
|
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
|
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
|
|
|
await mobile.navigateTab('run')
|
|
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
|
|
|
const steps = comfyPage.page.getByRole('spinbutton')
|
|
const initialValue = Number(await steps.inputValue())
|
|
await mobile.tap(
|
|
comfyPage.page.getByRole('button', { name: 'increment' }),
|
|
{ count: 5 }
|
|
)
|
|
await expect(steps).toHaveValue(String(initialValue + 5))
|
|
await mobile.tap(
|
|
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
|
{ count: 3 }
|
|
)
|
|
|
|
await expect(steps).toHaveValue(String(initialValue + 2))
|
|
})
|
|
|
|
test('workflow selection', async ({ comfyPage }) => {
|
|
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
|
for (const name of widgetNames)
|
|
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
|
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
|
|
|
const widgets = comfyPage.appMode.linearWidgets
|
|
await comfyPage.appMode.mobile.navigateTab('run')
|
|
for (let i = 0; i < widgetNames.length; i++) {
|
|
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
|
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
|
}
|
|
})
|
|
})
|
|
})
|