fix(web): pin sticky marketplace search without jumping (ECO-473)

Keep the standalone mobile search sticky below the header instead of
dropping sticky, and stop Chromium from scrolling the in-flow search box
into view when the desktop header search is focused or typed into.
This commit is contained in:
CodingOnStar 2026-08-30 00:22:57 +08:00
parent d8f30076b5
commit dfb8b2e376
10 changed files with 281 additions and 57 deletions

View File

@ -208,7 +208,7 @@ describe('Marketplace catalog tab handoff', () => {
expect(headerTabsSlot).toHaveAttribute('inert')
expect(
contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
).toBeCloseTo(72)
).toBeCloseTo(108)
await page.viewport(880, 800)
await vi.waitFor(() => {

View File

@ -30,34 +30,45 @@ const nextFrame = () =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
describe('Marketplace mobile search layout', () => {
it('keeps the sticky header brand and actions above the search while scrolling', async () => {
await page.viewport(390, 844)
const overlaps = (a: DOMRect, b: DOMRect) =>
a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
const screen = await render(
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
<HomeShell
banners={[]}
header={
<HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform />
}
hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />}
isMarketplacePlatform
navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />}
page="plugins"
search={
<HomeSearch enableSearchShortcut={false}>
<input
aria-label="Search plugins or templates"
style={{ display: 'block', height: 36, width: '100%' }}
/>
</HomeSearch>
}
>
<div aria-hidden style={{ height: 640, flexShrink: 0 }} />
</HomeShell>
</div>,
)
const isCenterClickable = (target: Element) => {
const rect = target.getBoundingClientRect()
const node = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
return Boolean(node && target.contains(node))
}
const renderMarketplaceHome = () =>
render(
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
<HomeShell
banners={[]}
header={
<HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform />
}
hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />}
isMarketplacePlatform
navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />}
page="plugins"
search={
<HomeSearch enableSearchShortcut={false}>
<input
aria-label="Search plugins or templates"
style={{ display: 'block', height: 36, width: '100%' }}
/>
</HomeSearch>
}
>
<div aria-hidden style={{ height: 640, flexShrink: 0 }} />
</HomeShell>
</div>,
)
describe('Marketplace mobile search layout', () => {
it('pins the mobile search below the header without covering brand or actions', async () => {
await page.viewport(390, 844)
const screen = await renderMarketplaceHome()
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const header = screen.getByRole('banner').element()
@ -67,26 +78,70 @@ describe('Marketplace mobile search layout', () => {
.getByRole('textbox', { name: 'Search plugins or templates' })
.element()
scrollContainer.scrollTop =
searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top - 6
scrollContainer.scrollTop = 400
scrollContainer.dispatchEvent(new Event('scroll'))
await nextFrame()
const headerRect = header.getBoundingClientRect()
const searchRect = searchInput.getBoundingClientRect()
const assertHeaderTargetIsClickable = (target: Element) => {
const targetRect = target.getBoundingClientRect()
const x = targetRect.left + targetRect.width / 2
const overlapTop = Math.max(targetRect.top, searchRect.top)
const overlapBottom = Math.min(targetRect.bottom, searchRect.bottom)
const y = overlapTop + (overlapBottom - overlapTop) / 2
expect(overlapBottom).toBeGreaterThan(overlapTop)
expect(searchRect.left).toBeLessThan(x)
expect(searchRect.right).toBeGreaterThan(x)
expect(target.contains(document.elementFromPoint(x, y))).toBe(true)
}
expect(searchRect.top).toBeGreaterThanOrEqual(headerRect.bottom - 1)
expect(searchRect.top).toBeLessThanOrEqual(headerRect.bottom + 2)
expect(overlaps(searchRect, brand.getBoundingClientRect())).toBe(false)
expect(overlaps(searchRect, signIn.getBoundingClientRect())).toBe(false)
expect(isCenterClickable(brand)).toBe(true)
expect(isCenterClickable(signIn)).toBe(true)
expect(isCenterClickable(searchInput)).toBe(true)
})
assertHeaderTargetIsClickable(brand)
assertHeaderTargetIsClickable(signIn)
it('keeps the desktop search in the header gap while scrolling', async () => {
await page.viewport(1280, 900)
const screen = await renderMarketplaceHome()
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const header = screen.getByRole('banner').element()
const searchInput = screen
.getByRole('textbox', { name: 'Search plugins or templates' })
.element()
scrollContainer.scrollTop = 400
scrollContainer.dispatchEvent(new Event('scroll'))
await nextFrame()
expect(
searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top,
).toBeCloseTo(6, 0)
})
it('does not jump the page when the stuck desktop search is focused or typed into', async () => {
await page.viewport(1280, 900)
const screen = await renderMarketplaceHome()
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const header = screen.getByRole('banner').element()
const searchInput = screen
.getByRole('textbox', { name: 'Search plugins or templates' })
.element()
scrollContainer.scrollTop = 400
scrollContainer.dispatchEvent(new Event('scroll'))
await nextFrame()
const scrollTopBefore = scrollContainer.scrollTop
const inputTopBefore = searchInput.getBoundingClientRect().top
expect(inputTopBefore - header.getBoundingClientRect().top).toBeCloseTo(6, 0)
const searchLocator = screen.getByRole('textbox', { name: 'Search plugins or templates' })
await searchLocator.click()
await nextFrame()
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
await searchLocator.fill('g')
await nextFrame()
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
})
})

View File

@ -0,0 +1,45 @@
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll'
const nextFrame = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
describe('Sticky search scroll guard', () => {
it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => {
await page.viewport(1280, 900)
const screen = await render(
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
<div style={{ height: 48, flexShrink: 0 }}>Header</div>
<div style={{ height: 180, flexShrink: 0 }}>Hero</div>
<div
data-testid="search-root"
style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }}
>
<input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} />
</div>
<div style={{ height: 900, flexShrink: 0 }}>Catalog</div>
</div>,
)
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const searchRoot = screen.getByTestId('search-root').element()
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
container.scrollTop = 400
container.dispatchEvent(new Event('scroll'))
await nextFrame()
const scrollTopBefore = container.scrollTop
HTMLInputElement.prototype.focus.call(input)
await nextFrame()
expect(container.scrollTop).toBe(scrollTopBefore)
stop()
})
})

View File

@ -83,7 +83,7 @@ function HomeCatalogNavigation({
}, [isMarketplacePlatform, setIsPinned])
return (
<div className={styles.catalogNavigationGroup} style={{ top: HOME_HEADER_HEIGHT_PX }}>
<div className={styles.catalogNavigationGroup}>
<div
ref={catalogTabsRegionRef}
className={cn('w-full shrink-0 bg-background-default', styles.catalogTabsRegion)}

View File

@ -1,10 +1,12 @@
/**
* Height of the marketplace home header in pixels. The sticky catalog
* navigation pins itself directly below the header, so the header height and
* the sticky offset must stay in sync; both read this constant.
* Height of the marketplace home header in pixels. Sticky home chrome reads
* this so the header, search, and catalog offsets cannot drift apart.
*/
export const HOME_HEADER_HEIGHT_PX = 48
/** Height of the home search row. HomeSearch and the mobile catalog offset both read this. */
export const HOME_SEARCH_HEIGHT_PX = 36
/** 40px icon tiles + 1px divider-subtle lines in the marketplace home hero. */
export const HERO_GRID_PITCH_PX = 41
export const HERO_ICON_SIZE_PX = 40

View File

@ -4,8 +4,11 @@ import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import { HOME_SEARCH_HEIGHT_PX } from './home-constants'
import styles from './home-sticky.module.css'
import MarketplacePluginSearch from './marketplace-plugin-search'
import { preserveStickySearchScroll } from './preserve-sticky-search-scroll'
type HomeSearchProps = {
children?: ReactNode
@ -21,6 +24,13 @@ const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps)
const searchRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation('plugin')
useEffect(() => {
const searchRoot = searchRef.current
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)
if (!searchRoot || !container) return
return preserveStickySearchScroll(searchRoot, container)
}, [])
useEffect(() => {
if (!enableSearchShortcut) return
@ -28,7 +38,7 @@ const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps)
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return
event.preventDefault()
searchRef.current?.querySelector('input')?.focus()
searchRef.current?.querySelector('input')?.focus({ preventScroll: true })
}
document.addEventListener('keydown', handleGlobalSearchShortcut)
@ -37,10 +47,8 @@ const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps)
return (
<div
className={cn(
'pointer-events-none sticky z-[60] -mt-9 flex h-9 shrink-0 justify-center',
styles.search,
)}
className={cn('pointer-events-none -mt-9 flex shrink-0 justify-center', styles.search)}
style={{ height: HOME_SEARCH_HEIGHT_PX }}
>
<div
ref={searchRef}

View File

@ -1,7 +1,8 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import type { MarketplaceBannerPage } from './banners'
import { cn } from '@langgenius/dify-ui/cn'
import { HOME_HEADER_HEIGHT_PX, HOME_SEARCH_HEIGHT_PX } from './home-constants'
import { HomeStickyStateProvider } from './home-sticky-state-provider'
import styles from './home-sticky.module.css'
import HomeTrending from './home-trending'
@ -38,6 +39,12 @@ export function HomeShell({
<div
className="flex min-h-full w-full shrink-0 flex-col bg-background-default"
data-marketplace-standalone={isMarketplacePlatform ? '' : undefined}
style={
{
'--home-header-height': `${HOME_HEADER_HEIGHT_PX}px`,
'--home-search-height': `${HOME_SEARCH_HEIGHT_PX}px`,
} as CSSProperties
}
>
{header}
<div className="relative flex w-full flex-col">

View File

@ -1,6 +1,6 @@
/* The header height and the sticky top offset of the catalog navigation are
inline styles driven by HOME_HEADER_HEIGHT_PX in home-constants.ts, so the
two values cannot drift apart. */
/* Sticky offsets read --home-header-height and --home-search-height from
HomeShell (HOME_HEADER_HEIGHT_PX / HOME_SEARCH_HEIGHT_PX). Desktop catalog
navigation still pins with an inline top of HOME_HEADER_HEIGHT_PX. */
.headerCatalogTabs {
display: flex;
@ -45,9 +45,12 @@
}
.search {
position: sticky;
z-index: 60;
top: 6px;
padding-right: 356px;
padding-left: 356px;
overflow-anchor: none;
}
.searchContent {
@ -98,8 +101,12 @@
}
:global([data-marketplace-standalone]) .search {
position: sticky;
z-index: 45;
top: var(--home-header-height, 48px);
padding-right: 20px;
padding-left: 20px;
background-color: var(--color-background-default);
}
:global([data-marketplace-standalone]) .searchContent {
@ -118,6 +125,7 @@
position: sticky;
z-index: 40;
display: block;
top: calc(var(--home-header-height, 48px) + var(--home-search-height, 36px));
background-color: var(--color-background-default);
}

View File

@ -0,0 +1,99 @@
const LARGE_SCROLL_JUMP_PX = 16
/**
* Sticky search sits in document flow below the hero, then visually pins in the
* header. Focusing or typing in that input makes Chromium scroll the layout box
* into view, which unpins the search and looks like the page rolling down.
* Remember the scroll position and snap back when a focused search input causes
* a large jump.
*/
export function preserveStickySearchScroll(searchRoot: HTMLElement, container: HTMLElement) {
let stableScrollTop = container.scrollTop
let suppressing = false
const remember = () => {
if (!suppressing) stableScrollTop = container.scrollTop
}
const restore = () => {
if (container.scrollTop === stableScrollTop) return
suppressing = true
container.scrollTop = stableScrollTop
requestAnimationFrame(() => {
suppressing = false
})
}
const onScroll = () => {
if (suppressing) return
if (searchRoot.contains(document.activeElement)) {
if (Math.abs(container.scrollTop - stableScrollTop) > LARGE_SCROLL_JUMP_PX) {
restore()
return
}
}
remember()
}
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
remember()
suppressing = true
requestAnimationFrame(() => {
restore()
suppressing = false
})
}
const onFocusIn = (event: FocusEvent) => {
if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
restore()
requestAnimationFrame(restore)
}
const onInput = (event: Event) => {
if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
restore()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
remember()
suppressing = true
requestAnimationFrame(() => {
suppressing = false
})
}
const patchInputFocus = (input: HTMLInputElement) => {
if (input.dataset.marketplaceSearchFocus === 'patched') return
input.dataset.marketplaceSearchFocus = 'patched'
const nativeFocus = input.focus.bind(input)
input.focus = (options) => nativeFocus({ ...options, preventScroll: true })
}
searchRoot.querySelectorAll('input').forEach((input) => {
patchInputFocus(input)
})
const observer = new MutationObserver(() => {
searchRoot.querySelectorAll('input').forEach((input) => {
patchInputFocus(input)
})
})
observer.observe(searchRoot, { childList: true, subtree: true })
container.addEventListener('scroll', onScroll, { passive: true })
searchRoot.addEventListener('pointerdown', onPointerDown, true)
searchRoot.addEventListener('focusin', onFocusIn)
searchRoot.addEventListener('input', onInput, true)
window.addEventListener('keydown', onKeyDown, true)
return () => {
observer.disconnect()
container.removeEventListener('scroll', onScroll)
searchRoot.removeEventListener('pointerdown', onPointerDown, true)
searchRoot.removeEventListener('focusin', onFocusIn)
searchRoot.removeEventListener('input', onInput, true)
window.removeEventListener('keydown', onKeyDown, true)
}
}

View File

@ -11,7 +11,7 @@ export const marketplaceSearchParamsParsers = {
)
.withDefault('all')
.withOptions({ history: 'replace', clearOnDefault: false, scroll: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
q: parseAsString.withDefault('').withOptions({ history: 'replace', scroll: false }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}