implement figma mobile header

This commit is contained in:
TerryM
2026-05-27 11:00:52 +08:00
parent 7546faf15e
commit 2b1874ab01
4 changed files with 211 additions and 4 deletions

View File

@@ -66,6 +66,11 @@ type LanguageDropdownProps = {
className?: string;
};
function languageButtonLabel(lang: Lang): string {
if (lang === "zh-CN") return "中";
return lang.toUpperCase();
}
function LanguageDropdown({
lang,
setLang,
@@ -158,10 +163,102 @@ function LanguageDropdown({
);
}
type MobileLanguageButtonProps = {
lang: Lang;
setLang: (lang: Lang) => void;
ariaLabel: string;
onOpen?: () => void;
};
function MobileLanguageButton({
lang,
setLang,
ariaLabel,
onOpen,
}: MobileLanguageButtonProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", closeOnOutside);
document.addEventListener("touchstart", closeOnOutside);
window.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutside);
document.removeEventListener("touchstart", closeOnOutside);
window.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div ref={rootRef} className="relative shrink-0">
<button
type="button"
onClick={() => {
onOpen?.();
setOpen((value) => !value);
}}
className="flex h-10 w-10 items-center justify-center rounded-full border border-[#58585d] bg-transparent text-[15px] font-normal leading-[15px] text-[#e4e4e6] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
>
{languageButtonLabel(lang)}
</button>
{open ? (
<div
className="absolute right-0 top-[calc(100%+0.5rem)] z-50 w-44 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl"
role="listbox"
aria-label={ariaLabel}
>
{LANG_OPTIONS.map((option) => {
const active = option.code === lang;
return (
<button
key={option.code}
type="button"
role="option"
aria-selected={active}
onClick={() => {
setLang(option.code as Lang);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition ${
active
? "bg-ark-gold/10 text-ark-gold2"
: "text-neutral-200 hover:bg-white/10 hover:text-white"
}`}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{active ? (
<Check className="h-4 w-4" strokeWidth={2.4} />
) : null}
</span>
<span className="truncate font-medium">{option.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
export function PublicLayout() {
const { t, lang, setLang } = useI18n();
const { pathname, search, hash } = useLocation();
const [open, setOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [q, setQ] = useState("");
const nav = useNavigate();
@@ -173,12 +270,102 @@ export function PublicLayout() {
if (!s) return;
nav(`/search?q=${encodeURIComponent(s)}`);
setOpen(false);
setMobileSearchOpen(false);
};
return (
<div className="min-h-full flex flex-col pb-20 md:pb-0">
<header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md">
<div className="mx-auto max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-[20px] py-[12px] md:hidden">
<Link
to="/"
className="flex h-[28px] w-[160px] shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
aria-label={t("brand")}
>
<img
src="/assets/ark-library/header-logo.svg"
alt="ARK LIBRARY"
className="h-full w-full object-contain"
width={160}
height={28}
decoding="async"
draggable={false}
/>
</Link>
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
<button
type="button"
onClick={() => {
setOpen(false);
setMobileSearchOpen((value) => !value);
}}
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-[#191921] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
aria-label={t("search")}
aria-expanded={mobileSearchOpen}
>
<img
src="/assets/ark-library/header-search.svg"
alt=""
className="h-6 w-6"
width={24}
height={24}
aria-hidden
draggable={false}
/>
</button>
<MobileLanguageButton
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
onOpen={() => {
setOpen(false);
setMobileSearchOpen(false);
}}
/>
<button
type="button"
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
onClick={() => {
setMobileSearchOpen(false);
setOpen((v) => !v);
}}
aria-label="menu"
aria-expanded={open}
>
{open ? (
<X size={24} strokeWidth={2.4} />
) : (
<img
src="/assets/ark-library/header-menu.svg"
alt=""
className="h-6 w-6"
width={24}
height={24}
aria-hidden
draggable={false}
/>
)}
</button>
</div>
</div>
{mobileSearchOpen ? (
<div className="border-t border-white/10 bg-[#08070c] px-5 pb-3 md:hidden max-[360px]:px-3">
<div className="flex h-11 items-center gap-2 rounded-full border border-[#2a2a31] bg-[#191921] px-4">
<SearchIcon size={18} className="shrink-0 text-[#a8a9ae]" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && goSearch()}
placeholder={t("searchPlaceholder")}
className="min-w-0 flex-1 bg-transparent text-sm text-neutral-100 outline-none placeholder:text-[#777985]"
/>
</div>
</div>
) : null}
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-0">
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
<Link
@@ -270,7 +457,7 @@ export function PublicLayout() {
{open ? (
<div className="grid gap-2 border-t border-ark-line bg-ark-nav px-4 py-3 min-[440px]:px-5 sm:px-6 md:px-9 min-[1200px]:hidden">
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<div className="mb-1 hidden items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 md:flex">
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input
value={q}
@@ -284,7 +471,7 @@ export function PublicLayout() {
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
className="mb-1"
className="mb-1 hidden md:block"
/>
<Link
to="/"