fix: stabilize marketplace home sticky navigation

This commit is contained in:
CodingOnStar 2026-07-28 15:40:05 +08:00
parent ecf72a0860
commit 8e670491a3
12 changed files with 145 additions and 40 deletions

View File

@ -68,6 +68,8 @@ describe('PluginTypeSwitch', () => {
await user.click(screen.getByRole('button', { name: 'category.models' }))
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model')
const update = onUrlUpdate.mock.calls.at(-1)?.[0]
expect(update?.searchParams.get('category')).toBe('model')
expect(update?.options.scroll).toBe(false)
})
})

View File

@ -48,8 +48,9 @@ describe('HomeCatalogNavigation', () => {
expect(navigationSection).toHaveClass(styles.catalogNavigation)
expect(navigationSection.firstElementChild).toHaveClass('w-full')
expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]')
const activeTab = screen.getByText('plugin.marketplace.home.plugins')
const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
expect(activeTab).toHaveAttribute('aria-current', 'page')
expect(activeTab).toHaveAttribute('href', '/plugins')
expect(activeTab.querySelector('[aria-hidden="true"]')).toHaveClass(
'absolute',
'h-0.5',
@ -62,9 +63,26 @@ describe('HomeCatalogNavigation', () => {
expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home')
})
it('keeps compact tabs clickable and hides the active indicator', () => {
render(<HomeCatalogTabs compact isMarketplacePlatform />)
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
expect(pluginsTab).toHaveAttribute('href', '/plugins')
expect(pluginsTab).toHaveClass('cursor-pointer')
expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
expect(templatesTab).toHaveAttribute('href', '/templates')
expect(templatesTab).toHaveClass('cursor-pointer')
})
it('links Dify users to the hosted Marketplace templates page', () => {
renderNavigation(false)
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
'href',
'https://marketplace.dify.ai/plugins?source=console',
)
expect(
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
).toHaveAttribute('href', 'https://marketplace.dify.ai/templates?source=console')
@ -128,4 +146,19 @@ describe('HomeCatalogNavigation', () => {
scrollContainer.remove()
})
it('prevents scroll anchoring from reversing the sticky threshold', () => {
const scrollContainer = document.createElement('div')
scrollContainer.id = 'marketplace-container'
document.body.appendChild(scrollContainer)
const { unmount } = renderNavigation(true)
expect(scrollContainer.style.overflowAnchor).toBe('none')
unmount()
expect(scrollContainer.style.overflowAnchor).toBe('')
scrollContainer.remove()
})
})

View File

@ -25,6 +25,11 @@ function HomeCatalogNavigation({ catalogTabs }: HomeCatalogNavigationProps) {
const scrollContainer = document.getElementById('marketplace-container')
if (!scrollContainer) return
const previousOverflowAnchor = scrollContainer.style.overflowAnchor
// The sticky section becomes shorter when its tabs move into the header.
// Prevent browser scroll anchoring from moving it back across the pin threshold.
scrollContainer.style.overflowAnchor = 'none'
const updatePinnedState = () => {
const pinTrigger = pinTriggerRef.current
if (!pinTrigger) return
@ -41,6 +46,7 @@ function HomeCatalogNavigation({ catalogTabs }: HomeCatalogNavigationProps) {
return () => {
scrollContainer.removeEventListener('scroll', updatePinnedState)
window.removeEventListener('resize', updatePinnedState)
scrollContainer.style.overflowAnchor = previousOverflowAnchor
}
}, [setIsPinned])

View File

@ -5,11 +5,17 @@ import { getMarketplaceUrl } from '@/utils/var'
type HomeCatalogTabsProps = {
className?: string
compact?: boolean
isMarketplacePlatform: boolean
}
const HomeCatalogTabs = ({ className, isMarketplacePlatform }: HomeCatalogTabsProps) => {
const HomeCatalogTabs = ({
className,
compact = false,
isMarketplacePlatform,
}: HomeCatalogTabsProps) => {
const { t } = useTranslation()
const pluginsHref = isMarketplacePlatform ? '/plugins' : getMarketplaceUrl('/plugins')
const templatesHref = isMarketplacePlatform ? '/templates' : getMarketplaceUrl('/templates')
return (
@ -17,24 +23,35 @@ const HomeCatalogTabs = ({ className, isMarketplacePlatform }: HomeCatalogTabsPr
aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })}
className={cn('flex h-8 items-center gap-1', className)}
>
<span
<Link
href={pluginsHref}
aria-current="page"
className="relative flex h-8 items-start px-[9px] pt-2 body-sm-medium text-text-accent"
className={cn(
'relative flex h-8 cursor-pointer items-start rounded-lg px-[9px] pt-2 body-sm-medium outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
compact ? 'bg-state-base-active text-text-primary' : 'text-text-accent',
)}
>
{t(($) => $['marketplace.home.plugins'], { ns: 'plugin' })}
<span
aria-hidden
className="absolute bottom-0 left-1/2 h-0.5 w-[21px] -translate-x-1/2 rounded-full bg-text-accent"
/>
</span>
{!compact && (
<span
aria-hidden
className="absolute bottom-0 left-1/2 h-0.5 w-[21px] -translate-x-1/2 rounded-full bg-text-accent"
/>
)}
</Link>
<Link
href={templatesHref}
className="flex h-8 items-center gap-2 rounded-[10px] p-2 body-sm-regular text-text-primary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
className={cn(
'flex h-8 cursor-pointer items-center gap-2 rounded-[10px] p-2 body-sm-regular outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
compact ? 'text-text-tertiary' : 'text-text-primary',
)}
>
<span>{t(($) => $['marketplace.home.templates'], { ns: 'plugin' })}</span>
<span className="flex items-center rounded-full bg-saas-dify-blue-accessible px-[5px] py-0.5 system-2xs-regular text-text-primary-on-surface uppercase">
{t(($) => $['marketplace.home.new'], { ns: 'plugin' })}
</span>
{!compact && (
<span className="flex items-center rounded-full bg-saas-dify-blue-accessible px-[5px] py-0.5 system-2xs-regular text-text-primary-on-surface uppercase">
{t(($) => $['marketplace.home.new'], { ns: 'plugin' })}
</span>
)}
</Link>
</nav>
)

View File

@ -18,7 +18,7 @@ function Guide() {
const docLink = useDocLink()
return (
<Link href={docLink()} target="_blank" rel="noopener noreferrer">
<Link href={docLink()} target="_blank" rel="noopener noreferrer" className={styles.guide}>
<Button variant="ghost" size="large" className="w-[94px] gap-0.5 px-3 text-text-primary">
<span aria-hidden className="i-ri-map-2-line size-5" />
<span className="px-1 system-md-medium">Guide</span>
@ -33,15 +33,18 @@ const HomeHeader = ({ actions, brandName, isMarketplacePlatform }: HomeHeaderPro
return (
<header
className={cn(
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 backdrop-blur-sm md:px-9',
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 py-1.5 md:px-9',
styles.header,
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Link href="/" className="flex h-full w-[142px] shrink-0 items-center">
<Link href="/" className={cn('flex h-full w-[142px] shrink-0 items-center', styles.brand)}>
<DifyLogo size="small" className="h-[18px] w-[39px] shrink-0" />
<span
className="ml-1 text-[17.684px] leading-[20.21px] font-medium whitespace-nowrap text-dify-logo-black not-italic [text-box-edge:cap] [text-box-trim:trim-both]"
className={cn(
'ml-1 text-[12.94px] leading-[14.786px] font-medium whitespace-nowrap text-dify-logo-black not-italic [text-box-edge:cap] [text-box-trim:trim-both]',
styles.brandName,
)}
style={{ fontFamily: "var(--font-family-brand, 'Söhne', var(--font-sans))" }}
>
{brandName ?? t(($) => $['mainNav.marketplace'])}
@ -50,6 +53,7 @@ const HomeHeader = ({ actions, brandName, isMarketplacePlatform }: HomeHeaderPro
<HomeStickyCatalogTabs>
<HomeCatalogTabs
className={styles.headerCatalogTabs}
compact
isMarketplacePlatform={isMarketplacePlatform}
/>
</HomeStickyCatalogTabs>

View File

@ -23,8 +23,13 @@ const HomeSearch = () => {
}, [])
return (
<div className={cn('sticky z-[60] -mt-9 flex h-9 shrink-0 justify-center px-4', styles.search)}>
<div ref={searchRef} className="relative w-full max-w-[420px]">
<div
className={cn(
'pointer-events-none sticky z-[60] -mt-9 flex h-9 shrink-0 justify-center px-4',
styles.search,
)}
>
<div ref={searchRef} className="pointer-events-auto relative w-full max-w-[420px]">
<SearchBoxWrapper
wrapperClassName="w-full max-w-none"
inputClassName="h-9 w-full rounded-[10px] bg-components-input-bg-normal [&>div]:px-2.5"

View File

@ -3,12 +3,7 @@
}
.headerCatalogTabs {
display: none;
height: 34px;
}
.headerCatalogTabs > [aria-current='page'] {
height: 34px;
display: flex;
}
.search {
@ -31,20 +26,42 @@
}
.catalogNavigationPinned {
padding-top: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-divider-regular);
padding-top: 16px;
padding-bottom: 15px;
background-color: var(--color-background-default);
}
@media (min-width: 1100px) {
.headerCatalogTabs {
display: flex;
}
.catalogTabsPinned {
display: none;
}
.catalogTabsPinned {
.categoriesPinned {
margin-top: 0;
}
.catalogContent {
min-height: calc(100vh - 112px);
min-height: calc(100dvh - 112px);
}
@media (max-width: 767px) {
.guide {
display: none;
}
.categoriesPinned {
margin-top: 0;
.search {
position: relative;
top: auto;
}
}
@media (max-width: 559px) {
.brand {
width: 39px;
}
.brandName {
display: none;
}
}

View File

@ -6,6 +6,7 @@ import HomeHeader from './home-header'
import HomeHero from './home-hero'
import HomeSearch from './home-search'
import { HomeStickyStateProvider } from './home-sticky-state-provider'
import styles from './home-sticky.module.css'
import HomeTrending from './home-trending'
type MarketplaceHomeProps = {
@ -47,6 +48,7 @@ const MarketplaceHome = ({
/>
<div className="contents [&>div]:bg-background-default!">
<ListWrapper
className={styles.catalogContent}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>

View File

@ -101,4 +101,14 @@ describe('ListWrapper', () => {
expect(screen.getAllByTestId('loading')).toHaveLength(1)
})
it('keeps the supplied layout constraint while category results are loading', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
const { container } = render(<ListWrapper className="catalog-content-min-height" />)
expect(container.firstElementChild).toHaveClass('catalog-content-min-height')
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,5 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import Loading from '@/app/components/base/loading'
import SortDropdown from '../sort-dropdown'
@ -6,10 +7,15 @@ import { useMarketplaceData } from '../state'
import List from './index'
type ListWrapperProps = {
className?: string
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
}
const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => {
const ListWrapper = ({
className,
showInstallButton,
linkToMarketplaceDetail,
}: ListWrapperProps) => {
const { t } = useTranslation()
const {
@ -28,7 +34,10 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper
scrollbarGutter: 'stable',
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
}}
className="relative flex grow flex-col bg-background-default-subtle px-8 py-2"
className={cn(
'relative flex grow flex-col bg-background-default-subtle px-8 py-2',
className,
)}
>
<div className="flex w-full grow flex-col">
{plugins && (

View File

@ -86,7 +86,7 @@ function PluginTypeSwitch({ className, variant = 'default' }: PluginTypeSwitchPr
isHero
? 'flex shrink-0 items-center gap-1 overflow-x-auto'
: isHome
? 'flex w-full shrink-0 items-center justify-start gap-1 overflow-x-auto'
? 'flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto'
: 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
className,
)}
@ -106,7 +106,7 @@ function PluginTypeSwitch({ className, variant = 'default' }: PluginTypeSwitchPr
isHero
? 'text-text-primary-on-surface'
: isHome
? cn('min-w-12 justify-center text-text-tertiary', styles.homeItem)
? cn('min-w-12 shrink-0 justify-center text-text-tertiary', styles.homeItem)
: 'text-text-tertiary',
!isActive &&
(isHero

View File

@ -8,7 +8,7 @@ export const marketplaceSearchParamsParsers = {
Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[],
)
.withDefault('all')
.withOptions({ history: 'replace', clearOnDefault: false }),
.withOptions({ history: 'replace', clearOnDefault: false, scroll: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}