mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
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:
parent
d8f30076b5
commit
dfb8b2e376
@ -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(() => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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' }),
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user