From 183d71eb7c27bc96ad747d90e47543d89cd56333 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:06:10 -0500 Subject: [PATCH] Restore native mobile page scrolling --- ui/src/components/Layout.tsx | 82 ++++++++++++++++++++-------- ui/src/components/ScrollToBottom.tsx | 61 +++++++++++++++++---- 2 files changed, 109 insertions(+), 34 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 3a58e614..9af35ada 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; @@ -177,28 +177,56 @@ export function Layout() { }; }, [isMobile, sidebarOpen, setSidebarOpen]); - const handleMainScroll = useCallback( - (event: UIEvent) => { - if (!isMobile) return; + const updateMobileNavVisibility = useCallback((currentTop: number) => { + const delta = currentTop - lastMainScrollTop.current; - const currentTop = event.currentTarget.scrollTop; - const delta = currentTop - lastMainScrollTop.current; + if (currentTop <= 24) { + setMobileNavVisible(true); + } else if (delta > 8) { + setMobileNavVisible(false); + } else if (delta < -8) { + setMobileNavVisible(true); + } - if (currentTop <= 24) { - setMobileNavVisible(true); - } else if (delta > 8) { - setMobileNavVisible(false); - } else if (delta < -8) { - setMobileNavVisible(true); - } + lastMainScrollTop.current = currentTop; + }, []); - lastMainScrollTop.current = currentTop; - }, - [isMobile], - ); + useEffect(() => { + if (!isMobile) { + setMobileNavVisible(true); + lastMainScrollTop.current = 0; + return; + } + + const onScroll = () => { + updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0); + }; + + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + + return () => { + window.removeEventListener("scroll", onScroll); + }; + }, [isMobile, updateMobileNavVisibility]); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + + document.body.style.overflow = isMobile ? "visible" : "hidden"; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isMobile]); return ( -
+
- -
+
+
+ +
+
{hasUnknownCompanyPrefix ? ( mainContent.clientHeight + 1; + + if (usesOwnScroll) { + return { type: "element" as const, element: mainContent }; + } + } + + return { type: "window" as const }; +} + +function distanceFromBottom(target: ReturnType) { + if (target.type === "element") { + return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight; + } + + const scroller = document.scrollingElement ?? document.documentElement; + return scroller.scrollHeight - window.scrollY - window.innerHeight; +} + /** - * Floating scroll-to-bottom button that appears when the user is far from the - * bottom of the `#main-content` scroll container. Hides when within 300px of - * the bottom. Positioned to avoid the mobile bottom nav. + * Floating scroll-to-bottom button that follows the active page scroller. + * On desktop that is `#main-content`; on mobile it falls back to window/page scroll. */ export function ScrollToBottom() { const [visible, setVisible] = useState(false); useEffect(() => { - const el = document.getElementById("main-content"); - if (!el) return; const check = () => { - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - setVisible(distance > 300); + setVisible(distanceFromBottom(resolveScrollTarget()) > 300); }; + + const mainContent = document.getElementById("main-content"); + check(); - el.addEventListener("scroll", check, { passive: true }); - return () => el.removeEventListener("scroll", check); + mainContent?.addEventListener("scroll", check, { passive: true }); + window.addEventListener("scroll", check, { passive: true }); + window.addEventListener("resize", check); + + return () => { + mainContent?.removeEventListener("scroll", check); + window.removeEventListener("scroll", check); + window.removeEventListener("resize", check); + }; }, []); const scroll = useCallback(() => { - const el = document.getElementById("main-content"); - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + const target = resolveScrollTarget(); + + if (target.type === "element") { + target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" }); + return; + } + + const scroller = document.scrollingElement ?? document.documentElement; + window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }, []); if (!visible) return null;