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,
|
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">
|
||||||
{(
|
{(
|
||||||
|
|||||||
Reference in New Issue
Block a user