diff --git a/app/globals.css b/app/globals.css index ddfda28..7ac8715 100644 --- a/app/globals.css +++ b/app/globals.css @@ -967,3 +967,258 @@ a.underline-brutal:hover .arrow { border-top: 1px solid color-mix(in srgb, var(--ink) 15%, transparent); margin-block: 2.4em; } + +/* --- mobile menu ------------------------------------------------------ + Phone-only ( = new Set([ export function Chrome() { const [active, setActive] = useState<(typeof SECTION_IDS)[number]>("intro"); const [scrolled, setScrolled] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); const navRef = useRef(null); + const handleMobileOpenChange = useCallback( + (open: boolean) => setMobileOpen(open), + [], + ); + // Active section = whichever one paints the strip directly under the // navbar. We collapse the IO root into a 1px horizontal slice at y = // navbar.offsetHeight via rootMargin — the section currently @@ -110,6 +117,12 @@ export function Chrome() { }, []); const onDark = DARK_SECTIONS.has(active); + // The toggle is portalled to (see MobileMenu.tsx), so it + // paints on top of the white panel while the drawer is open — force + // it to paper then. The rest of the nav stays at z-50 behind the + // drawer, visible only in the strip the panel doesn't cover, and + // keeps tracking `onDark`. + const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx new file mode 100644 index 0000000..38a95f3 --- /dev/null +++ b/components/site/MobileMenu.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createPortal } from "react-dom"; +import { links } from "@/lib/links"; + +type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; + +type Props = { + activeSection: SectionId; + tone: "ink" | "paper"; + onOpenChange: (open: boolean) => void; +}; + +const SECTIONS: readonly { id: SectionId; label: string }[] = [ + { id: "compare", label: "compare" }, + { id: "method", label: "method" }, + { id: "faq", label: "faq" }, + { id: "contact", label: "contact" }, +] as const; + +type DrawerLink = { + href: string; + label: string; + kind: "internal" | "external"; +}; + +const EXTERNAL: readonly DrawerLink[] = [ + { kind: "internal", href: links.docs, label: "Docs" }, + { kind: "internal", href: links.blog, label: "Blog" }, + { kind: "external", href: links.litepaper, label: "Litepaper" }, +] as const; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +// Hydration gate for the createPortal target. Reads false on the +// server and true after hydration, without a useState+useEffect +// round-trip that would trip the react-hooks/set-state-in-effect rule. +const noopSubscribe = () => () => {}; +const getHydratedSnapshot = () => true; +const getServerSnapshot = () => false; + +export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { + const [open, setOpen] = useState(false); + // Static export: document.body isn't available during prerender. Gate + // the portal until after hydration so the first client render matches + // the prerendered HTML. + const isHydrated = useSyncExternalStore( + noopSubscribe, + getHydratedSnapshot, + getServerSnapshot, + ); + + const toggleRef = useRef(null); + const panelRef = useRef(null); + // iOS Safari leaks scroll through `overflow: hidden` on the body. Pin + // the body via `position: fixed; top: -y` instead — the layout + // viewport moves with us so there's nothing to scroll. + const prevBodyStyleRef = useRef<{ + position: string; + top: string; + left: string; + right: string; + width: string; + overflow: string; + } | null>(null); + const scrollYRef = useRef(0); + // When the drawer closes via an in-page anchor tap, the cleanup must + // scroll to that target instead of restoring the prior scroll + // position — otherwise the body unlock would override the anchor jump. + const pendingAnchorRef = useRef(null); + + useEffect(() => { + onOpenChange(open); + }, [open, onOpenChange]); + + useEffect(() => { + if (!open) return; + + const scrollY = window.scrollY; + scrollYRef.current = scrollY; + const style = document.body.style; + prevBodyStyleRef.current = { + position: style.position, + top: style.top, + left: style.left, + right: style.right, + width: style.width, + overflow: style.overflow, + }; + style.position = "fixed"; + style.top = `-${scrollY}px`; + style.left = "0"; + style.right = "0"; + style.width = "100%"; + style.overflow = "hidden"; + + // Menu content doesn't change while open, so no need to re-query on + // every Tab keystroke. The portalled toggle is appended manually so + // keyboard users can Tab from the last row to the close ×. + const panel = panelRef.current!; + const focusables: HTMLElement[] = Array.from( + panel.querySelectorAll(FOCUSABLE_SELECTOR), + ); + if (toggleRef.current) focusables.push(toggleRef.current); + + // rAF before the initial focus so the panel's open transition is in + // flight first — otherwise iOS Safari scrolls the focused element + // into view through the transform. + const raf = requestAnimationFrame(() => { + focusables[0]?.focus({ preventScroll: true }); + }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + return; + } + if (e.key !== "Tab") return; + const first = focusables[0]!; + const last = focusables[focusables.length - 1]!; + const activeEl = document.activeElement as HTMLElement | null; + + if (e.shiftKey && activeEl === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && activeEl === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKey); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("keydown", onKey); + // Removing `position: fixed` snaps the page back to y=0 because + // the negative `top` was the only thing holding the viewport in + // place — scroll somewhere explicitly after restoring styles. + const prev = prevBodyStyleRef.current!; + const style = document.body.style; + style.position = prev.position; + style.top = prev.top; + style.left = prev.left; + style.right = prev.right; + style.width = prev.width; + style.overflow = prev.overflow; + prevBodyStyleRef.current = null; + + const anchor = pendingAnchorRef.current; + pendingAnchorRef.current = null; + if (anchor) { + const el = document.getElementById(anchor); + if (el) { + // history.replaceState doesn't scroll; smooth-scroll via + // scrollIntoView. `motion-safe:scroll-smooth` on + // honours reduced-motion automatically. + history.replaceState(null, "", `#${anchor}`); + el.scrollIntoView({ block: "start" }); + } else { + // Anchor target lives on another route (drawer opened from + // /blog/*). Let the browser navigate to home + hash. + window.location.assign(`/#${anchor}`); + } + } else { + window.scrollTo(0, scrollYRef.current); + } + }; + }, [open]); + + // Skipped on the initial render (when `open` is already false) via + // the wasOpenRef guard. + const wasOpenRef = useRef(false); + useEffect(() => { + if (wasOpenRef.current && !open) { + toggleRef.current?.focus({ preventScroll: true }); + } + wasOpenRef.current = open; + }, [open]); + + // Close on resize to desktop. Without this the CSS hides the panel + // but `open` stays true, leaving body scroll locked and the nav + // stuck in forced-paper tone. + useEffect(() => { + const mql = window.matchMedia("(min-width: 768px)"); + const onChange = (e: MediaQueryListEvent) => { + if (e.matches) setOpen(false); + }; + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + const close = useCallback(() => setOpen(false), []); + + // For an in-page anchor, preventDefault so the browser doesn't start + // its own scroll while the body is still pinned; stash the target id + // for the cleanup to consume after the body unlock. + const handleClick = useCallback((e: React.MouseEvent) => { + const href = e.currentTarget.getAttribute("href"); + if (href?.startsWith("#")) { + e.preventDefault(); + pendingAnchorRef.current = href.slice(1); + } + setOpen(false); + }, []); + + // Portal target: toggle/overlay/panel all live in so the + // toggle can sit above the panel (z-65 > z-60) while the wordmark + // stays inside the nav at z-50 and is naturally covered when the + // drawer opens. Raising the nav's z-index would lift the wordmark + // above the drawer too, which it shouldn't be. + const drawer = ( + <> +