Restore native mobile page scrolling

This commit is contained in:
Dotta
2026-03-10 21:06:10 -05:00
parent 3273692944
commit 183d71eb7c
2 changed files with 109 additions and 34 deletions

View File

@@ -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<HTMLElement>) => {
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 (
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -287,14 +315,22 @@ export function Layout() {
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll}
className={cn(
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage

View File

@@ -1,29 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
function resolveScrollTarget() {
const mainContent = document.getElementById("main-content");
if (mainContent instanceof HTMLElement) {
const overflowY = window.getComputedStyle(mainContent).overflowY;
const usesOwnScroll =
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
if (usesOwnScroll) {
return { type: "element" as const, element: mainContent };
}
}
return { type: "window" as const };
}
function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
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;