feat(header): make nav-vs-burger decision per-language by measuring fit
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 38s

Each translation gives the nav a different natural width: Malay's 6 labels
total ~594px while Chinese is ~508px. With a fixed Tailwind breakpoint
(`xl:flex`) some languages either clipped at viewports where the nav
technically didn't fit, or collapsed too early to a burger on wide screens.

Drive the toggle from runtime measurement instead:

- Always render a hidden ghost nav (`absolute invisible`) so the browser
  can report the inline nav's true scrollWidth for the active locale,
  even while we're showing the burger.
- Add refs on the header row, brand block, and right-side actions; a
  useLayoutEffect + ResizeObserver compares ghost.scrollWidth against
  (rowWidth - brandWidth - rightWidth - 2*rowGap).
- 60px hysteresis on burger -> inline so the layout doesn't oscillate
  when the favorites label / burger button swap changes right-side width.
- Drop the now-unused `xl:flex`, `xl:hidden`, `xl:flex-none`,
  `xl:inline` classes from the affected elements.
- Close the burger drawer automatically when the row grows wide enough
  to show the inline nav again, so the menu doesn't get stuck without
  a toggle.

Verified via puppeteer/eval across en / zh-CN / zh-TW / ms / ru / de at
800-2000px: each language switches to burger at its own threshold and
the inline nav never overflows or clips its labels.
This commit is contained in:
TerryM
2026-06-08 01:01:33 +08:00
parent 6aaa9573e7
commit 2a702f4e12

View File

@@ -6,7 +6,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
import { pageTransition } from "../motion"; import { pageTransition } from "../motion";
import { ArkLogoMark } from "../components/ArkLogoMark"; import { ArkLogoMark } from "../components/ArkLogoMark";
@@ -309,6 +309,17 @@ export function PublicLayout() {
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null); const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
const desktopSearchRef = useRef<HTMLDivElement>(null); const desktopSearchRef = useRef<HTMLDivElement>(null);
const desktopSearchPanelRef = useRef<HTMLDivElement>(null); const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
// Runtime fit detection for the desktop nav. Different languages and page
// titles change the natural width of the nav and brand, so a fixed CSS
// breakpoint (e.g. `xl:flex`) clips or overlaps text in some translations.
// Instead we render a hidden “ghost” nav, measure its scrollWidth against
// the space left after the brand + right-side actions, and toggle to the
// burger menu the moment it would no longer fit.
const [showInlineNav, setShowInlineNav] = useState(true);
const headerRowRef = useRef<HTMLDivElement>(null);
const headerBrandRef = useRef<HTMLDivElement>(null);
const headerRightRef = useRef<HTMLDivElement>(null);
const ghostNavRef = useRef<HTMLElement>(null);
const nav = useNavigate(); const nav = useNavigate();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
@@ -387,6 +398,50 @@ export function PublicLayout() {
}, [lang]); }, [lang]);
const popularHref = lp("/browse?sort=popular"); const popularHref = lp("/browse?sort=popular");
// Decide whether the inline nav fits next to the brand and right-side
// actions for the current language. Hysteresis (60px) on the
// burger -> inline transition keeps the layout from oscillating when the
// right-side width shrinks slightly after we hide the burger button.
useLayoutEffect(() => {
const row = headerRowRef.current;
const brand = headerBrandRef.current;
const right = headerRightRef.current;
const ghost = ghostNavRef.current;
if (!row || !brand || !right || !ghost) return;
const measure = () => {
const rowWidth = row.clientWidth;
if (rowWidth === 0) return;
const rowStyle = window.getComputedStyle(row);
const rowGap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0");
const totalGap = rowGap * 2; // brand <-> nav <-> right
const available =
rowWidth - brand.offsetWidth - right.offsetWidth - totalGap;
const needed = ghost.scrollWidth;
setShowInlineNav((current) => {
if (current) return needed <= available;
// Need real breathing room before switching back, to avoid flicker
// when the right-side width shrinks after the burger button hides.
return needed + 60 <= available;
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(row);
ro.observe(ghost);
ro.observe(brand);
ro.observe(right);
return () => ro.disconnect();
}, [lang, pageTitle, t]);
// When the layout grows enough that we move back to the inline nav, the
// burger drawer would otherwise stay stuck open with no visible toggle.
useEffect(() => {
if (showInlineNav) setOpen(false);
}, [showInlineNav]);
const goSearch = () => { const goSearch = () => {
const s = q.trim(); const s = q.trim();
if (!s) return; if (!s) return;
@@ -611,12 +666,18 @@ export function PublicLayout() {
</div> </div>
<div className="hidden w-full px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-10"> <div className="hidden w-full px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-10">
{/* Single row (md+): logo | nav | search + language. Header now spans {/* Single row (md+): logo | nav | search + language. The nav and the
the full viewport so wide monitors get more breathing room, and burger toggle are driven by a runtime fit measurement (see
the nav switches to the burger menu at `xl` so it never has to useLayoutEffect above) instead of a fixed Tailwind breakpoint,
clip / horizontally-scroll the labels. */} so each language collapses to the burger at its own width. */}
<div className="flex h-10 items-center gap-2 xl:gap-4"> <div
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"> ref={headerRowRef}
className="relative flex h-10 items-center gap-2 xl:gap-4"
>
<div
ref={headerBrandRef}
className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"
>
{/* Logo → home; page-name text → scroll to top of the current page. */} {/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to={homePath} to={homePath}
@@ -640,55 +701,79 @@ export function PublicLayout() {
</button> </button>
</div> </div>
{/* Hidden measurement copy of the nav. Always rendered so the
ResizeObserver can ask the browser “how wide would the inline
nav need to be?” without flickering the visible layout. */}
<nav <nav
className="hidden min-w-0 flex-1 items-center justify-center gap-4 py-0.5 xl:flex xl:gap-5" ref={ghostNavRef}
aria-label={t("mainNav")} aria-hidden
tabIndex={-1}
className="pointer-events-none invisible absolute left-0 top-0 flex h-10 items-center gap-4 py-0.5 whitespace-nowrap xl:gap-5"
> >
<Link <span className={navClassName(false)}>{t("all")}</span>
to={lp("/browse")} <span className={navClassName(false)}>{t("categories")}</span>
className={navClassName(na("browseAll"))} <span className={navClassName(false)}>{t("official")}</span>
aria-current={na("browseAll") ? "page" : undefined} <span className={navClassName(false)}>{t("latest")}</span>
> <span className={navClassName(false)}>{t("favorites")}</span>
{t("all")} <span className={navClassName(false)}>{t("popular")}</span>
</Link>
<Link
to={lp("/categories")}
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
to={lp("/official-recommendations")}
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
to={lp("/browse?sort=latest")}
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link
to={popularHref}
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
>
{t("popular")}
</Link>
</nav> </nav>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 xl:flex-none"> {showInlineNav ? (
<nav
className="flex min-w-0 flex-1 items-center justify-center gap-4 py-0.5 xl:gap-5"
aria-label={t("mainNav")}
>
<Link
to={lp("/browse")}
className={navClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
>
{t("all")}
</Link>
<Link
to={lp("/categories")}
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
to={lp("/official-recommendations")}
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
to={lp("/browse?sort=latest")}
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link
to={popularHref}
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
>
{t("popular")}
</Link>
</nav>
) : (
<div className="min-w-0 flex-1" />
)}
<div
ref={headerRightRef}
className="flex shrink-0 items-center justify-end gap-2"
>
<div ref={desktopSearchRef} className="hidden md:block"> <div ref={desktopSearchRef} className="hidden md:block">
<button <button
type="button" type="button"
@@ -721,23 +806,27 @@ export function PublicLayout() {
aria-current={na("favorites") ? "page" : undefined} aria-current={na("favorites") ? "page" : undefined}
> >
<Heart className="h-[18px] w-[18px]" strokeWidth={2.2} /> <Heart className="h-[18px] w-[18px]" strokeWidth={2.2} />
<span className="hidden xl:inline">{t("favorites")}</span> {showInlineNav ? (
<span className="inline">{t("favorites")}</span>
) : null}
</Link> </Link>
<div className="hidden md:block"> <div className="hidden md:block">
<WalletButton /> <WalletButton />
</div> </div>
<button {showInlineNav ? null : (
ref={desktopMenuButtonRef} <button
type="button" ref={desktopMenuButtonRef}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg xl:hidden" type="button"
onClick={() => { className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
setDesktopSearchOpen(false); onClick={() => {
setOpen((v) => !v); setDesktopSearchOpen(false);
}} setOpen((v) => !v);
aria-label="menu" }}
> aria-label="menu"
{open ? <X size={18} /> : <Menu size={18} />} >
</button> {open ? <X size={18} /> : <Menu size={18} />}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -746,7 +835,7 @@ export function PublicLayout() {
{open ? ( {open ? (
<div <div
ref={menuRef} ref={menuRef}
className={`${headerMenuAnimationClass} fixed inset-x-0 bottom-0 top-[64px] z-50 flex flex-col bg-ark-bg/90 backdrop-blur-xl md:top-[70px] xl:hidden`} className={`${headerMenuAnimationClass} fixed inset-x-0 bottom-0 top-[64px] z-50 flex flex-col bg-ark-bg/90 backdrop-blur-xl md:top-[70px]`}
> >
<nav className="flex-1 overflow-y-auto px-5"> <nav className="flex-1 overflow-y-auto px-5">
{( {(