feat: 优化语言切换下拉交互并支持玩法规则多语言内容

This commit is contained in:
2026-05-22 16:55:45 +08:00
parent 52702c9fbb
commit 2bf44e4c29
5 changed files with 126 additions and 89 deletions

View File

@@ -3,9 +3,10 @@
import "@/i18n";
import { ChevronDown, Globe } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
import { cn } from "@/lib/utils";
@@ -28,7 +29,6 @@ export function LanguageSwitcher({
const { i18n, t } = useTranslation("common");
const active = normalizeLanguage(i18n.language) as AppLanguage;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const options = useMemo(
() =>
@@ -40,22 +40,6 @@ export function LanguageSwitcher({
[t],
);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
async function handleSelect(code: AppLanguage): Promise<void> {
await i18n.changeLanguage(code);
setIsOpen(false);
@@ -91,72 +75,74 @@ export function LanguageSwitcher({
menuAlign ?? (variant === "header" || variant === "default" ? "start" : "end");
return (
<div ref={containerRef} className={cn("relative inline-block", className)}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
className={cn("inline-flex", className)}
render={
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
styles.button,
)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={`Language (${currentLabel})`}
>
<Globe className="size-4" aria-hidden />
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
{showLabel ? <span>{currentLabel}</span> : null}
<ChevronDown
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
aria-hidden
/>
</button>
}
/>
<PopoverContent
align={align}
side="bottom"
sideOffset={6}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
styles.button,
"z-[200] w-auto min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900",
styles.dropdown,
)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={`Language (${currentLabel})`}
>
<Globe className="size-4" aria-hidden />
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
{showLabel ? <span>{currentLabel}</span> : null}
<ChevronDown
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
aria-hidden
/>
</button>
{isOpen ? (
<div
className={cn(
"absolute z-[100] mt-1 min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] rounded-lg py-1 text-gray-900 shadow-md",
align === "start" ? "left-0" : "right-0",
styles.dropdown,
)}
role="listbox"
>
<div className="max-h-[280px] overflow-y-auto">
{options.map((option) => (
<button
key={option.code}
type="button"
onClick={() => void handleSelect(option.code)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
active === option.code ? styles.activeItem : styles.item,
)}
role="option"
aria-selected={active === option.code}
>
<span className="text-lg">{option.flag}</span>
<div className="flex flex-col leading-tight">
<span className="font-medium">{option.label}</span>
<span className="text-xs opacity-60">{option.short}</span>
</div>
{active === option.code ? (
<svg
className="ml-auto size-4 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>{option.label}</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
<div className="max-h-[min(280px,50dvh)] overflow-y-auto" role="listbox">
{options.map((option) => (
<button
key={option.code}
type="button"
onClick={() => void handleSelect(option.code)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
active === option.code ? styles.activeItem : styles.item,
)}
role="option"
aria-selected={active === option.code}
>
<span className="text-lg">{option.flag}</span>
<div className="flex flex-col leading-tight">
<span className="font-medium">{option.label}</span>
<span className="text-xs opacity-60">{option.short}</span>
</div>
{active === option.code ? (
<svg
className="ml-auto size-4 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
@@ -166,6 +152,11 @@ export function LanguageSwitcherMinimal({
className?: string;
}): ReactNode {
return (
<LanguageSwitcher variant="minimal" className={className} showFlag={false} />
<LanguageSwitcher
variant="minimal"
menuAlign="end"
className={className}
showFlag={false}
/>
);
}

View File

@@ -44,7 +44,12 @@ export function PlayerPanel({
className,
)}
>
<header className={cn(playerPageHeader, "sticky top-0 z-50 bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3")}>
<header
className={cn(
playerPageHeader,
"sticky top-0 z-50 overflow-visible bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
)}
>
<div className="flex min-w-0 justify-start">
<Link
href={backHref}
@@ -67,6 +72,7 @@ export function PlayerPanel({
<div className="flex min-w-0 items-center justify-end gap-1">
<LanguageSwitcher
variant="minimal"
menuAlign="end"
showFlag={false}
className={cn(
playerHeaderControl,