implement figma mobile header
This commit is contained in:
@@ -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="/"
|
||||
|
||||
Reference in New Issue
Block a user