diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx index eefefafa3d5..8c07de518af 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx @@ -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(() => { diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx index 5596c641fb8..7902cb2858c 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx @@ -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( -
- Sign in} isMarketplacePlatform /> - } - hero={
} - isMarketplacePlatform - navigation={
} - page="plugins" - search={ - - - - } - > -
- -
, - ) +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( +
+ Sign in} isMarketplacePlatform /> + } + hero={
} + isMarketplacePlatform + navigation={
} + page="plugins" + search={ + + + + } + > +
+ +
, + ) + +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) }) }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx new file mode 100644 index 00000000000..da5a92037dc --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx @@ -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((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( +
+
Header
+
Hero
+
+ +
+
Catalog
+
, + ) + + 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() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx index a47ca7be738..28f75b772b8 100644 --- a/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx +++ b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx @@ -83,7 +83,7 @@ function HomeCatalogNavigation({ }, [isMarketplacePlatform, setIsPinned]) return ( -
+
(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 (
{header}
diff --git a/web/app/components/plugins/marketplace/home/home-sticky.module.css b/web/app/components/plugins/marketplace/home/home-sticky.module.css index 77b36600614..8a8687bb7cc 100644 --- a/web/app/components/plugins/marketplace/home/home-sticky.module.css +++ b/web/app/components/plugins/marketplace/home/home-sticky.module.css @@ -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); } diff --git a/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts new file mode 100644 index 00000000000..cd4e3167833 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts @@ -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) + } +} diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index d4506367ff7..1c22597c69f 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -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' }), }