feat: refine language menu and lightbox caption

This commit is contained in:
TerryM
2026-05-26 18:37:17 +08:00
parent 532f0112fd
commit 54f71c6ab3
2 changed files with 159 additions and 65 deletions

View File

@@ -1,5 +1,12 @@
import { Globe, Menu, Search as SearchIcon, X } from "lucide-react";
import { useState } from "react";
import {
Check,
ChevronDown,
Globe,
Menu,
Search as SearchIcon,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { ArkLogoMark } from "../components/ArkLogoMark";
import { useI18n, type Lang } from "../i18n";
@@ -55,6 +62,105 @@ function navClassName(active: boolean) {
].join(" ");
}
type LanguageDropdownProps = {
lang: Lang;
setLang: (lang: Lang) => void;
ariaLabel: string;
className?: string;
};
function LanguageDropdown({
lang,
setLang,
ariaLabel,
className = "",
}: LanguageDropdownProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = LANG_OPTIONS.find((option) => option.code === lang);
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 ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex h-full w-full items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
>
<Globe size={16} className="shrink-0 text-ark-gold/80" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">
{selected?.label ?? lang}
</span>
<ChevronDown
size={16}
className={`shrink-0 text-neutral-400 transition ${
open ? "rotate-180 text-ark-gold" : ""
}`}
aria-hidden
/>
</button>
{open ? (
<div
className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 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();
@@ -154,25 +260,12 @@ export function PublicLayout() {
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
/>
</div>
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
<Globe
size={16}
className="shrink-0 text-ark-gold/80"
aria-hidden
/>
<select
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 lg:max-w-none"
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")}
>
{LANG_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</div>
<LanguageDropdown
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40"
/>
<button
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 min-[1200px]:hidden"
@@ -197,21 +290,12 @@ export function PublicLayout() {
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
/>
</div>
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<Globe size={16} className="shrink-0 text-ark-gold/80" />
<select
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")}
>
{LANG_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</div>
<LanguageDropdown
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
className="mb-1"
/>
<Link
to="/"
className={navClassName(na("home"))}