feat(header): make nav-vs-burger decision per-language by measuring fit
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 38s
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:
@@ -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,55 +701,79 @@ 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"
|
||||
aria-label={t("mainNav")}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -721,23 +806,27 @@ 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>
|
||||
<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"
|
||||
onClick={() => {
|
||||
setDesktopSearchOpen(false);
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label="menu"
|
||||
>
|
||||
{open ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
{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"
|
||||
onClick={() => {
|
||||
setDesktopSearchOpen(false);
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label="menu"
|
||||
>
|
||||
{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">
|
||||
{(
|
||||
|
||||
Reference in New Issue
Block a user