Restore native mobile page scrolling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user