mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +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`
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
|
|
import {
|
|
comfyPageFixture as test,
|
|
comfyExpect as expect
|
|
} from '@e2e/fixtures/ComfyPage'
|
|
|
|
/**
|
|
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
|
* All widgets from the default graph are selected so the panel scrolls,
|
|
* pushing the last widget's dropdown to the clipping boundary.
|
|
*/
|
|
const DEFAULT_INPUTS: [string, string][] = [
|
|
['4', 'ckpt_name'],
|
|
['6', 'text'],
|
|
['7', 'text'],
|
|
['5', 'width'],
|
|
['5', 'height'],
|
|
['5', 'batch_size'],
|
|
['3', 'seed'],
|
|
['3', 'steps'],
|
|
['3', 'cfg'],
|
|
['3', 'sampler_name'],
|
|
['3', 'scheduler'],
|
|
['3', 'denoise'],
|
|
['9', 'filename_prefix']
|
|
]
|
|
|
|
function isClippedByAnyAncestor(el: Element): boolean {
|
|
const child = el.getBoundingClientRect()
|
|
let parent = el.parentElement
|
|
|
|
while (parent) {
|
|
const overflow = getComputedStyle(parent).overflow
|
|
if (overflow !== 'visible') {
|
|
const p = parent.getBoundingClientRect()
|
|
if (
|
|
child.top < p.top ||
|
|
child.bottom > p.bottom ||
|
|
child.left < p.left ||
|
|
child.right > p.right
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
parent = parent.parentElement
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Add a node to the graph by type and return its ID. */
|
|
async function addNode(page: Page, nodeType: string): Promise<string> {
|
|
return page.evaluate((type) => {
|
|
const node = window.app!.graph.add(
|
|
window.LiteGraph!.createNode(type, undefined, {})
|
|
)
|
|
return String(node!.id)
|
|
}, nodeType)
|
|
}
|
|
|
|
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.appMode.enableLinearMode()
|
|
})
|
|
|
|
test('Select dropdown is not clipped in app mode panel', async ({
|
|
comfyPage
|
|
}) => {
|
|
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
|
await comfyPage.nextFrame()
|
|
|
|
const inputs: [string, string][] = [
|
|
...DEFAULT_INPUTS,
|
|
[saveVideoId, 'codec']
|
|
]
|
|
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
|
|
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
|
|
|
// Scroll to bottom so the codec widget is at the clipping edge
|
|
const widgetList = comfyPage.appMode.linearWidgets
|
|
await widgetList.evaluate((el) =>
|
|
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
|
)
|
|
|
|
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
|
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
|
await codecSelect.click()
|
|
|
|
const overlay = comfyPage.page
|
|
.getByTestId('widget-select-default-overlay')
|
|
.first()
|
|
await expect(overlay).toBeVisible()
|
|
|
|
await expect
|
|
.poll(() =>
|
|
overlay.evaluate((el) => {
|
|
const rect = el.getBoundingClientRect()
|
|
return (
|
|
rect.top >= 0 &&
|
|
rect.left >= 0 &&
|
|
rect.bottom <= window.innerHeight &&
|
|
rect.right <= window.innerWidth
|
|
)
|
|
})
|
|
)
|
|
.toBe(true)
|
|
|
|
await expect
|
|
.poll(() => overlay.evaluate(isClippedByAnyAncestor))
|
|
.toBe(false)
|
|
})
|
|
|
|
test('FormDropdown popup is not clipped in app mode panel', async ({
|
|
comfyPage
|
|
}) => {
|
|
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
|
await comfyPage.nextFrame()
|
|
|
|
const inputs: [string, string][] = [
|
|
...DEFAULT_INPUTS,
|
|
[loadImageId, 'image']
|
|
]
|
|
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
|
|
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
|
|
|
// Scroll to bottom so the image widget is at the clipping edge
|
|
const widgetList = comfyPage.appMode.linearWidgets
|
|
await widgetList.evaluate((el) =>
|
|
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
|
)
|
|
|
|
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
|
`${loadImageId}:image`
|
|
)
|
|
await imageInput.open()
|
|
|
|
// 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()
|
|
|
|
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
|
|
)
|
|
})
|
|
)
|
|
.toBe(true)
|
|
|
|
await expect
|
|
.poll(() => popover.evaluate(isClippedByAnyAncestor))
|
|
.toBe(false)
|
|
})
|
|
})
|