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; }