From 27f3747bef313162c35bd9631655a772ea10e266 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:03:32 -0700 Subject: [PATCH] fix(promptfields): keep wildcard autocomplete open while scrolling The suggestion list element is marked with data-prompt-trigger-autocomplete. The dismiss-on-scroll check now exempts that element and popover content, using closest() so an option inside the list counts as internal. Window resize still dismisses, because its target is not an element scroll. A browser test supplies 24 wildcards, types the opening trigger, scrolls the overflowing listbox to the bottom, and asserts that the same listbox remains open with scrollTop greater than zero. Fixes #9971 --- .../PositivePromptField.browser.test.tsx | 71 ++++++++++++++++++- .../PromptTriggerAutocomplete.tsx | 2 + .../useDismissOnViewportChange.ts | 15 +++- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.browser.test.tsx index 8d01dbc28be..69e639e4412 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.browser.test.tsx @@ -1,5 +1,6 @@ /* oxlint-disable react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop */ import type { GenerateModelConfig } from '@features/generation/core/types'; +import type { WildcardRecord } from '@features/generation/data/wildcards'; import { ChakraProvider } from '@chakra-ui/react'; import { DndContext } from '@dnd-kit/core'; @@ -8,10 +9,11 @@ import { adjustFocusedPromptAttention } from '@features/generation/ui/promptFiel import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import i18next from 'i18next'; -import { act } from 'react'; +import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; const i18n = i18next.createInstance(); @@ -29,6 +31,8 @@ vi.mock('@features/generation/ui/GenerationUiContext', async (importOriginal) => }), })); +const wildcardRecords: WildcardRecord[] = []; + vi.mock('@features/generation/data/wildcards', async (importOriginal) => ({ // Partial: the real module's other exports (constants, mappers) stay available to what imports them. ...(await importOriginal>()), @@ -36,7 +40,10 @@ vi.mock('@features/generation/data/wildcards', async (importOriginal) => ({ deleteWildcard: vi.fn(), invalidateWildcardDependents: vi.fn(), updateWildcard: vi.fn(), - wildcardsQueryOptions: () => ({ queryFn: () => Promise.resolve([]), queryKey: ['generation', 'wildcards'] }), + wildcardsQueryOptions: () => ({ + queryFn: () => Promise.resolve(wildcardRecords), + queryKey: ['generation', 'wildcards'], + }), })); let host: HTMLDivElement | null = null; @@ -48,6 +55,7 @@ afterEach(async () => { host?.remove(); host = null; root = null; + wildcardRecords.length = 0; }); it('keeps positive prompt actions enabled when stale view mode has no active template', async () => { @@ -148,3 +156,62 @@ it('colours and weights attention syntax only while the selected model reads it' expect(await weightCat()).toBe(true); await expect.poll(() => textarea().value).toBe('a cat++'); }); + +it('keeps overflowing wildcard suggestions open when the list scrolls', async () => { + wildcardRecords.push( + ...Array.from({ length: 24 }, (_, index) => ({ id: `wildcard-${index}`, name: `w${index}`, values: ['red'] })) + ); + const Prompt = () => { + const [value, setValue] = useState(''); + + return ( + + ); + }; + + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(() => { + root?.render( + + + + + + + + + + ); + }); + await act(async () => { + await userEvent.type(host!.querySelector('textarea')!, '__'); + }); + await expect.poll(() => document.querySelector('[role="listbox"]')).not.toBeNull(); + + const list = document.querySelector('[role="listbox"]')!; + + expect(list.scrollHeight).toBeGreaterThan(list.clientHeight); + await act(async () => { + const scrolled = new Promise((resolve) => { + list.addEventListener('scroll', () => resolve(), { once: true }); + }); + + list.scrollTop = list.scrollHeight; + await scrolled; + }); + + expect(document.querySelector('[role="listbox"]')).toBe(list); + expect(list.scrollTop).toBeGreaterThan(0); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx index a636e8c2692..dc393f4ba34 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx @@ -7,6 +7,7 @@ import type { MouseEvent } from 'react'; import { Box, Portal, Stack, Text } from '@chakra-ui/react'; import { groupPromptTriggerOptions } from '@features/generation/ui/promptFields/promptTriggerOptions'; +import { PROMPT_TRIGGER_AUTOCOMPLETE_SCROLL_PROPS } from '@features/generation/ui/promptFields/useDismissOnViewportChange'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -70,6 +71,7 @@ export const PromptTriggerAutocomplete = ({ return ( void): void => { useMountEffect(() => { if (!enabled) { @@ -8,7 +18,8 @@ export const useDismissOnViewportChange = (enabled: boolean, dismiss: () => void } const handleViewportChange = (event: Event) => { - if (event.target instanceof Element && event.target.closest('[data-scope="popover"][data-part="content"]')) { + // Resize targets the window, so it still dismisses. Only element scrolls can be internal. + if (event.target instanceof Element && event.target.closest(EXEMPT_SCROLL_SELECTOR)) { return; }