diff --git a/src/components/header.tsx b/src/components/header.tsx index c00e48b684d7df..c51b32230da37e 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,6 +6,7 @@ import dynamic from 'next/dynamic'; import Image from 'next/image'; import Link from 'next/link'; import {useCallback, useEffect, useState} from 'react'; +import {useBodyScrollLock} from 'sentry-docs/hooks/useBodyScrollLock'; import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg'; import {Platform} from 'sentry-docs/types'; @@ -60,46 +61,70 @@ export function Header({ }, []) ); - // Close mobile search overlay on navigation + // Close mobile overlays on navigation useEffect(() => { setMobileSearchOpen(false); + setHomeMobileNavOpen(false); }, [pathname]); - // Lock body scroll when mobile search overlay is open, close on resize to desktop or escape key + const closeSidebar = useCallback(() => { + const checkbox = document.getElementById(sidebarToggleId) as HTMLInputElement | null; + if (checkbox) { + checkbox.checked = false; + } + setSidebarOpen(false); + }, []); + + useBodyScrollLock(mobileSearchOpen); + useBodyScrollLock(homeMobileNavOpen); + + // Close the home mobile nav if the viewport is resized to desktop width useEffect(() => { - if (mobileSearchOpen) { - document.body.style.overflow = 'hidden'; + if (!homeMobileNavOpen) { + return undefined; + } + const handleResize = () => { + if (window.innerWidth >= 768) { + setHomeMobileNavOpen(false); + } + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [homeMobileNavOpen]); - // Close mobile search if viewport is resized to desktop width - const handleResize = () => { - if (window.innerWidth >= 768) { - setMobileSearchOpen(false); - } - }; + // Close mobile search on resize to desktop or escape key + useEffect(() => { + if (!mobileSearchOpen) { + return undefined; + } - // Close mobile search on escape key (only if search input is empty) - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Check if search input has a value - if so, let the Search component - // handle the escape key to clear the query first - const searchInput = document.querySelector( - '.mobile-search-overlay input[type="text"]' - ) as HTMLInputElement | null; - if (!searchInput?.value) { - setMobileSearchOpen(false); - } + // Close mobile search if viewport is resized to desktop width + const handleResize = () => { + if (window.innerWidth >= 768) { + setMobileSearchOpen(false); + } + }; + + // Close mobile search on escape key (only if search input is empty) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Check if search input has a value - if so, let the Search component + // handle the escape key to clear the query first + const searchInput = document.querySelector( + '.mobile-search-overlay input[type="text"]' + ) as HTMLInputElement | null; + if (!searchInput?.value) { + setMobileSearchOpen(false); } - }; + } + }; - window.addEventListener('resize', handleResize); - window.addEventListener('keydown', handleKeyDown); - return () => { - document.body.style.overflow = ''; - window.removeEventListener('resize', handleResize); - window.removeEventListener('keydown', handleKeyDown); - }; - } - return undefined; + window.addEventListener('resize', handleResize); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('keydown', handleKeyDown); + }; }, [mobileSearchOpen]); // Track sidebar checkbox state for non-home pages @@ -126,31 +151,28 @@ export function Header({ return () => checkbox.removeEventListener('change', handleChange); }, [isHomePage]); - // Lock body scroll when sidebar is open on mobile (fixes iOS Safari touch scrolling) + useBodyScrollLock(sidebarOpen); + + // Close sidebar if viewport is resized to desktop width useEffect(() => { - if (sidebarOpen) { - document.body.style.overflow = 'hidden'; + if (!sidebarOpen) { + return undefined; + } - // Close sidebar if viewport is resized to desktop width - const handleResize = () => { - if (window.innerWidth >= 768) { - const checkbox = document.getElementById( - sidebarToggleId - ) as HTMLInputElement | null; - if (checkbox) { - checkbox.checked = false; - setSidebarOpen(false); - } + const handleResize = () => { + if (window.innerWidth >= 768) { + const checkbox = document.getElementById( + sidebarToggleId + ) as HTMLInputElement | null; + if (checkbox) { + checkbox.checked = false; + setSidebarOpen(false); } - }; + } + }; - window.addEventListener('resize', handleResize); - return () => { - document.body.style.overflow = ''; - window.removeEventListener('resize', handleResize); - }; - } - return undefined; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); }, [sidebarOpen]); // Show header search if: not on home page, OR on home page but home search is scrolled out of view @@ -283,14 +305,7 @@ export function Header({ onClick={() => { setMobileSearchOpen(true); setHomeMobileNavOpen(false); - // Close sidebar to prevent competing scroll locks - const checkbox = document.getElementById( - sidebarToggleId - ) as HTMLInputElement | null; - if (checkbox) { - checkbox.checked = false; - setSidebarOpen(false); - } + closeSidebar(); }} aria-label="Search" > @@ -350,7 +365,7 @@ export function Header({ {/* Home page mobile navigation overlay */} {isHomePage && homeMobileNavOpen && (
diff --git a/src/components/search/index.tsx b/src/components/search/index.tsx index 2fed908d2d15cf..52cfecf08fbd8a 100644 --- a/src/components/search/index.tsx +++ b/src/components/search/index.tsx @@ -60,6 +60,8 @@ const search = new SentryGlobalSearch(config); type Props = { autoFocus?: boolean; + /** Called before the Kapa modal opens, so a parent overlay can close itself first. */ + onAskAi?: () => void; path?: string; searchPlatforms?: string[]; showChatBot?: boolean; @@ -83,6 +85,7 @@ const SDK_AGNOSTIC_PATH_PREFIXES = [ export function Search({ path, autoFocus, + onAskAi, searchPlatforms = [], useStoredSearchPlatforms = true, }: Props) { @@ -373,10 +376,13 @@ export function Search({ className={styles['sgs-ai-button']} onClick={() => { if (window.Kapa?.open) { - // close search results setInputFocus(false); - // open kapa modal - window.Kapa.open({query, submit: true}); + onAskAi?.(); + // Open next frame, after the overlay's scroll lock is released + // on commit, so Kapa's lock and ours never overlap. + requestAnimationFrame(() => { + window.Kapa?.open({query, submit: true}); + }); } }} > diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx index 86c9d4bc021cc6..2f3812c1cbe933 100644 --- a/src/components/sidebar/MobileSidebarNav.tsx +++ b/src/components/sidebar/MobileSidebarNav.tsx @@ -1,5 +1,6 @@ 'use client'; +import {ChevronDownIcon} from '@radix-ui/react-icons'; import Link from 'next/link'; import {usePathname} from 'next/navigation'; import {useEffect, useState} from 'react'; @@ -12,6 +13,11 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { const isActive = (href: string) => pathname?.startsWith(href); + // Collapse all top-level sections behind a single "Menu" disclosure so the + // current section's page nav stays near the top on mobile. Collapsed by + // default; the active section is conveyed by the page nav heading below. + const [menuOpen, setMenuOpen] = useState(false); + // Compute the SDK link href - use stored platform URL if available const [sdkLinkHref, setSdkLinkHref] = useState('/platforms/'); @@ -61,37 +67,52 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { return (
- {/* Main navigation sections - simple links that navigate to index pages */} - + + {menuOpen && ( + // Main navigation sections - simple links that navigate to index pages + + )}
); } diff --git a/src/components/sidebar/style.module.scss b/src/components/sidebar/style.module.scss index 48e33b53eea5a5..627da63de22822 100644 --- a/src/components/sidebar/style.module.scss +++ b/src/components/sidebar/style.module.scss @@ -68,6 +68,11 @@ @media only screen and (max-width: 767px) { &:has(> #navbar-menu-toggle:checked) { display: flex; + // On mobile the whole sidebar is a single scroll container, so the + // expanded section menu and the page nav scroll together instead of + // fighting over the fixed height with a clipped inner scroll region. + overflow-y: auto; + overscroll-behavior: contain; & + :global(.main-content) { margin-left: 0; @@ -93,6 +98,14 @@ @media only screen and (min-width: 768px) { display: block; } + + // On mobile the outer .sidebar is the single scroll container, so let .toc + // grow naturally instead of nesting its own scroll region. + @media only screen and (max-width: 767px) { + flex: 0 0 auto; + overflow: visible; + min-height: auto; + } } :global(.toc-item) { @@ -155,6 +168,13 @@ overflow: auto; min-height: 0; // Critical for flex children to allow shrinking and enable scrolling -webkit-overflow-scrolling: touch; // Smooth momentum scrolling on iOS + + // On mobile the outer .sidebar is the single scroll container (see .sidebar). + @media only screen and (max-width: 767px) { + flex: 0 0 auto; + overflow: visible; + min-height: auto; + } } .sidebar-external-links { @@ -229,7 +249,7 @@ padding: 0.5rem 0 0.375rem; margin-top: 0.5rem; list-style: none; - + &:first-child { margin-top: 0; } diff --git a/src/hooks/useBodyScrollLock.tsx b/src/hooks/useBodyScrollLock.tsx new file mode 100644 index 00000000000000..2ee47205e2c7e7 --- /dev/null +++ b/src/hooks/useBodyScrollLock.tsx @@ -0,0 +1,51 @@ +'use client'; + +import {useEffect, useLayoutEffect} from 'react'; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +let lockCount = 0; + +function lockBodyScroll() { + if (lockCount === 0) { + // Lock the documentElement (the actual scroll container) as well as body, + // and disable overscroll so the page can't rubber-band behind a fixed + // overlay at the scroll bounds (which detaches it from the header on iOS). + const html = document.documentElement; + html.style.overflow = 'hidden'; + html.style.overscrollBehavior = 'none'; + document.body.style.overflow = 'hidden'; + } + lockCount += 1; +} + +function unlockBodyScroll() { + if (lockCount === 0) { + return; + } + lockCount -= 1; + if (lockCount === 0) { + // Clear, don't restore a captured value — it may be a third party's + // transient lock (e.g. Kapa); re-applying it would strand the page. + const html = document.documentElement; + html.style.overflow = ''; + html.style.overscrollBehavior = ''; + document.body.style.overflow = ''; + } +} + +/** + * Reference-counted body scroll lock; safe to use from several overlays at once. + * Runs as a layout effect so the release happens synchronously on commit, before + * any rAF that hands the lock off to a third-party modal. + */ +export function useBodyScrollLock(active: boolean) { + useIsomorphicLayoutEffect(() => { + if (!active) { + return undefined; + } + lockBodyScroll(); + return unlockBodyScroll; + }, [active]); +}