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,
} from "lucide-react";
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 { pageTransition } from "../motion";
import { ArkLogoMark } from "../components/ArkLogoMark";
@@ -309,6 +309,17 @@ export function PublicLayout() {
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
const desktopSearchRef = 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 lp = useLocalizedPath();
@@ -387,6 +398,50 @@ export function PublicLayout() {
}, [lang]);
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 s = q.trim();
if (!s) return;
@@ -611,12 +666,18 @@ export function PublicLayout() {
</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">
{/* Single row (md+): logo | nav | search + language. Header now spans
the full viewport so wide monitors get more breathing room, and
the nav switches to the burger menu at `xl` so it never has to
clip / horizontally-scroll the labels. */}
<div className="flex h-10 items-center gap-2 xl:gap-4">
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">
{/* Single row (md+): logo | nav | search + language. The nav and the
burger toggle are driven by a runtime fit measurement (see
useLayoutEffect above) instead of a fixed Tailwind breakpoint,
so each language collapses to the burger at its own width. */}
<div
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. */}
<Link
to={homePath}
@@ -640,8 +701,26 @@ export function PublicLayout() {
</button>
</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
className="hidden min-w-0 flex-1 items-center justify-center gap-4 py-0.5 xl:flex xl:gap-5"
ref={ghostNavRef}
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"
>
<span className={navClassName(false)}>{t("all")}</span>
<span className={navClassName(false)}>{t("categories")}</span>
<span className={navClassName(false)}>{t("official")}</span>
<span className={navClassName(false)}>{t("latest")}</span>
<span className={navClassName(false)}>{t("favorites")}</span>
<span className={navClassName(false)}>{t("popular")}</span>
</nav>
{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
@@ -687,8 +766,14 @@ export function PublicLayout() {
{t("popular")}
</Link>
</nav>
) : (
<div className="min-w-0 flex-1" />
)}
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 xl:flex-none">
<div
ref={headerRightRef}
className="flex shrink-0 items-center justify-end gap-2"
>
<div ref={desktopSearchRef} className="hidden md:block">
<button
type="button"
@@ -721,15 +806,18 @@ export function PublicLayout() {
aria-current={na("favorites") ? "page" : undefined}
>
<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>
<div className="hidden md:block">
<WalletButton />
</div>
{showInlineNav ? null : (
<button
ref={desktopMenuButtonRef}
type="button"
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"
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"
onClick={() => {
setDesktopSearchOpen(false);
setOpen((v) => !v);
@@ -738,6 +826,7 @@ export function PublicLayout() {
>
{open ? <X size={18} /> : <Menu size={18} />}
</button>
)}
</div>
</div>
</div>
@@ -746,7 +835,7 @@ export function PublicLayout() {
{open ? (
<div
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">
{(