fix: prevent marketplace search layout shift (ECO-462)

This commit is contained in:
CodingOnStar 2026-08-27 15:33:42 +08:00
parent 54f4073f22
commit abb8f5c239
3 changed files with 303 additions and 114 deletions

View File

@ -0,0 +1,164 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
import HomeCatalogNavigation from '../home-catalog-navigation'
import HomeSearch from '../home-search'
import { homeCatalogPinnedAtom } from '../home-sticky-state'
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete'
const { mockTemplateSearch } = vi.hoisted(() => ({
mockTemplateSearch: vi.fn(),
}))
vi.mock('ahooks', async (importOriginal) => {
const original = await importOriginal<typeof import('ahooks')>()
return {
...original,
useDebounce: <T,>(value: T) => value,
}
})
vi.mock('react-i18next', async (importOriginal) => {
const original = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...original,
...createReactI18nextMock({
clearSearch: 'Clear search',
loading: 'Loading',
'marketplace.loadError': 'Failed to load. Please try again.',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
}),
}
})
vi.mock('@/service/client', async (importOriginal) => {
const original = await importOriginal<typeof import('@/service/client')>()
return {
...original,
marketplaceQuery: {
searchAdvanced: {
queryOptions: ({ input }: { input: unknown }) => ({
queryKey: ['marketplace', 'plugins', input],
queryFn: () => ({ data: { plugins: [], total: 0 } }),
}),
},
templateSearch: {
queryOptions: ({ input }: { input: unknown }) => ({
queryKey: ['marketplace', 'templates', input],
queryFn: () => mockTemplateSearch(input),
}),
},
},
}
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 0,
retry: false,
},
},
})
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
function StickyTemplateSearch() {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search templates"
scope="templates"
value={value}
/>
)
}
function PinnedHeaderState() {
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
return (
<header className="sticky top-0 z-50 flex h-12 items-center bg-background-default">
<span>Dify Marketplace</span>
{isCatalogPinned && (
<div role="tablist" aria-label="Header catalog tabs">
Plugins and templates
</div>
)}
</header>
)
}
describe('Marketplace search autocomplete layout', () => {
beforeEach(() => {
queryClient.clear()
mockTemplateSearch.mockReset()
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
})
it('keeps the pinned catalog layout stable while the results popup opens', async () => {
await page.viewport(1280, 720)
const screen = await render(
<Wrapper>
<HomeStickyStateProvider>
<div
id={MARKETPLACE_CONTAINER_ID}
data-marketplace-standalone
data-testid="marketplace-scroll-container"
className="h-[360px] w-[1200px] overflow-y-auto"
>
<PinnedHeaderState />
<div className="h-[180px]" aria-hidden />
<HomeSearch enableSearchShortcut={false}>
<StickyTemplateSearch />
</HomeSearch>
<HomeCatalogNavigation
catalogCategories={<div role="group" aria-label="Template categories" />}
catalogTabs={<div role="tablist" aria-label="Catalog tabs" />}
/>
<main aria-label="Template catalog" className="h-[900px]" />
</div>
</HomeStickyStateProvider>
</Wrapper>,
)
const scrollContainer = screen.getByTestId('marketplace-scroll-container').element()
scrollContainer.scrollTop = 220
scrollContainer.dispatchEvent(new Event('scroll'))
await new Promise(requestAnimationFrame)
const input = screen.getByRole('combobox', { name: 'Search templates' })
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
const catalogNavigation = screen
.getByRole('region', { name: 'common.mainNav.marketplace' })
.element()
const scrollTopBefore = scrollContainer.scrollTop
const inputTopBefore = input.element().getBoundingClientRect().top
const navigationTopBefore = catalogNavigation.getBoundingClientRect().top
await input.fill('open')
await expect.element(screen.getByText('No templates found')).toBeVisible()
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
})
})

View File

@ -363,13 +363,16 @@ describe('MarketplaceSearchAutocomplete', () => {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
<>
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
<button type="button">Outside search</button>
</>
)
}
@ -378,10 +381,10 @@ describe('MarketplaceSearchAutocomplete', () => {
await user.type(screen.getByRole('combobox'), 'google')
expect(screen.getByText(/Loading/)).toBeInTheDocument()
// Base UI marks the rest of the document inert while the popup is open,
// so the outside-press target is its Dismiss control — not a sibling button.
await user.click(screen.getAllByRole('button', { name: 'Dismiss' })[0]!)
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Outside search' }))
await waitFor(() => {
expect(screen.getByText(/Loading/)).not.toBeVisible()
})
resolvePluginSearch(pluginResponse)

View File

@ -11,7 +11,6 @@ import {
AutocompleteItemIndicator,
AutocompleteItemText,
AutocompleteList,
AutocompletePopup,
AutocompletePortal,
AutocompletePositioner,
AutocompleteStatus,
@ -19,7 +18,7 @@ import {
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { renderI18nObject } from '@/i18n-config/index'
@ -96,6 +95,8 @@ export function MarketplaceSearchAutocomplete({
}: MarketplaceSearchAutocompleteProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const searchRootRef = useRef<HTMLDivElement>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
const hasQuery = Boolean(debouncedSearch)
const searchesPlugins = scope === 'all' || scope === 'plugins'
@ -170,110 +171,131 @@ export function MarketplaceSearchAutocomplete({
? t(($) => $['newApp.noTemplateFound'], { ns: 'app' })
: t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' })
useEffect(() => {
if (!isPopupOpen) return
const handleOutsidePress = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (searchRootRef.current?.contains(target) || resultsPanelRef.current?.contains(target))
return
setIsOpen(false)
}
document.addEventListener('click', handleOutsidePress)
return () => document.removeEventListener('click', handleOutsidePress)
}, [isPopupOpen])
return (
<Autocomplete
filter={null}
itemToStringValue={(item) => item.label}
items={suggestions}
mode="list"
name={inputName}
onOpenChange={setIsOpen}
onValueChange={(nextValue) => {
onValueChange(nextValue)
setIsOpen(Boolean(nextValue.trim()))
}}
open={isPopupOpen}
openOnInputClick
submitOnItemClick={Boolean(inputName)}
value={value}
>
<AutocompleteInputGroup size="large">
<span
aria-hidden
className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<AutocompleteInput
aria-label={placeholder}
className="px-2 text-sm"
placeholder={placeholder}
size="large"
type="text"
/>
{!!value && (
<AutocompleteClear
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
size="large"
<div ref={searchRootRef} className="relative">
<Autocomplete
filter={null}
itemToStringValue={(item) => item.label}
items={suggestions}
mode="list"
name={inputName}
onOpenChange={setIsOpen}
onValueChange={(nextValue) => {
onValueChange(nextValue)
setIsOpen(Boolean(nextValue.trim()))
}}
open={isPopupOpen}
openOnInputClick
submitOnItemClick={Boolean(inputName)}
value={value}
>
<AutocompleteInputGroup size="large">
<span
aria-hidden
className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
)}
</AutocompleteInputGroup>
<AutocompletePortal hidden={!isPopupOpen}>
<AutocompletePositioner sideOffset={8}>
<AutocompletePopup className="max-w-[420px]" aria-busy={isSearching || undefined}>
<AutocompleteList<MarketplaceSuggestion>>
{(item) => (
<AutocompleteItem
key={item.id}
value={item}
className="items-start py-2"
onClick={
onSuggestionSelect
? () => {
onSuggestionSelect(item.selection)
queueMicrotask(() => {
onValueChange('')
setIsOpen(false)
})
}
: undefined
}
>
{item.iconUrl ? (
<img
alt=""
className="mt-0.5 size-6 shrink-0 rounded-md object-contain"
src={item.iconUrl}
onError={({ currentTarget }) => {
currentTarget.style.display = 'none'
}}
/>
) : (
<span
aria-hidden
className={cn(
'mt-0.5 size-4 shrink-0 text-text-tertiary',
item.kind === 'template' ? 'i-ri-layout-grid-line' : 'i-ri-puzzle-2-line',
<AutocompleteInput
aria-label={placeholder}
className="px-2 text-sm"
placeholder={placeholder}
size="large"
type="text"
/>
{!!value && (
<AutocompleteClear
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
size="large"
/>
)}
</AutocompleteInputGroup>
<AutocompletePortal hidden={!isPopupOpen}>
<AutocompletePositioner sideOffset={8}>
<div
ref={resultsPanelRef}
className="w-(--anchor-width) max-w-[420px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden"
aria-busy={isSearching || undefined}
>
<AutocompleteList<MarketplaceSuggestion>>
{(item) => (
<AutocompleteItem
key={item.id}
value={item}
className="items-start py-2"
onClick={
onSuggestionSelect
? () => {
onSuggestionSelect(item.selection)
queueMicrotask(() => {
onValueChange('')
setIsOpen(false)
})
}
: undefined
}
>
{item.iconUrl ? (
<img
alt=""
className="mt-0.5 size-6 shrink-0 rounded-md object-contain"
src={item.iconUrl}
onError={({ currentTarget }) => {
currentTarget.style.display = 'none'
}}
/>
) : (
<span
aria-hidden
className={cn(
'mt-0.5 size-4 shrink-0 text-text-tertiary',
item.kind === 'template' ? 'i-ri-layout-grid-line' : 'i-ri-puzzle-2-line',
)}
/>
)}
<span className="flex min-w-0 grow flex-col gap-0.5">
<AutocompleteItemText className="px-0 text-text-primary">
{item.label}
</AutocompleteItemText>
{!!item.description && (
<span className="line-clamp-2 system-xs-regular text-text-tertiary">
{item.description}
</span>
)}
/>
)}
<span className="flex min-w-0 grow flex-col gap-0.5">
<AutocompleteItemText className="px-0 text-text-primary">
{item.label}
</AutocompleteItemText>
{!!item.description && (
<span className="line-clamp-2 system-xs-regular text-text-tertiary">
{item.description}
</span>
)}
{!!item.meta && (
<span className="truncate system-xs-regular text-text-quaternary">
{item.meta}
</span>
)}
</span>
<AutocompleteItemIndicator />
</AutocompleteItem>
)}
</AutocompleteList>
<AutocompleteEmpty>
{!isSearching && suggestions.length === 0 ? emptyText : null}
</AutocompleteEmpty>
<AutocompleteStatus>
{isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
</AutocompleteStatus>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompletePortal>
</Autocomplete>
{!!item.meta && (
<span className="truncate system-xs-regular text-text-quaternary">
{item.meta}
</span>
)}
</span>
<AutocompleteItemIndicator />
</AutocompleteItem>
)}
</AutocompleteList>
<AutocompleteEmpty>
{!isSearching && suggestions.length === 0 ? emptyText : null}
</AutocompleteEmpty>
<AutocompleteStatus>
{isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
</AutocompleteStatus>
</div>
</AutocompletePositioner>
</AutocompletePortal>
</Autocomplete>
</div>
)
}