返回 CodeWhale
mobile-menu.tsx
根目录 / web / components / mobile-menu.tsx
1 "use client";
2
3 import Link from "next/link";
4 import { usePathname } from "next/navigation";
5 import { useEffect, useState } from "react";
6 import type { ChromeLink } from "@/lib/i18n/links";
7
8 export function MobileMenu({
9 links,
10 installHref,
11 installLabel,
12 openLabel,
13 closeLabel,
14 }: {
15 links: ChromeLink[];
16 installHref: string;
17 installLabel: string;
18 openLabel: string;
19 closeLabel: string;
20 }) {
21 const [open, setOpen] = useState(false);
22 const pathname = usePathname();
23
24 useEffect(() => {
25 if (!open) return;
26 const prev = document.body.style.overflow;
27 document.body.style.overflow = "hidden";
28 const onKey = (e: KeyboardEvent) => {
29 if (e.key === "Escape") setOpen(false);
30 };
31 window.addEventListener("keydown", onKey);
32 return () => {
33 document.body.style.overflow = prev;
34 window.removeEventListener("keydown", onKey);
35 };
36 }, [open]);
37
38 return (
39 <>
40 <button
41 type="button"
42 onClick={() => setOpen((o) => !o)}
43 className="md:hidden inline-flex items-center justify-center w-9 h-9 hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
44 aria-label={open ? closeLabel : openLabel}
45 aria-expanded={open}
46 aria-controls="mobile-menu"
47 >
48 {open ? (
49 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
50 <path d="M2 2L12 12M12 2L2 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
51 </svg>
52 ) : (
53 <svg width="16" height="12" viewBox="0 0 16 12" fill="none" aria-hidden>
54 <path d="M0 1H16M0 6H16M0 11H16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
55 </svg>
56 )}
57 </button>
58
59 {open && (
60 <div
61 id="mobile-menu"
62 className="md:hidden fixed inset-x-0 top-[5.75rem] bottom-0 z-40 bg-paper hairline-t overflow-y-auto"
63 role="dialog"
64 aria-modal="true"
65 >
66 <nav className="px-6 py-4">
67 <ul className="divide-y divide-[rgba(27,34,48,0.18)]">
68 {links.map((l) => {
69 const isActive = pathname === l.href || pathname.startsWith(`${l.href}/`);
70 return (
71 <li key={l.href}>
72 <Link
73 href={l.href}
74 onClick={() => setOpen(false)}
75 className={`flex items-baseline gap-3 py-4 hover:text-indigo transition-colors ${isActive ? "text-indigo" : ""}`}
76 aria-current={isActive ? "page" : undefined}
77 >
78 <span className="font-display text-lg">{l.label}</span>
79 {l.secondary && (
80 <span className="font-cjk text-sm text-ink-mute">{l.secondary}</span>
81 )}
82 <span className="ml-auto font-mono text-xs text-ink-mute">→</span>
83 </Link>
84 </li>
85 );
86 })}
87 </ul>
88
89 <Link
90 href={installHref}
91 onClick={() => setOpen(false)}
92 className="mt-6 block w-full text-center px-5 py-3 bg-indigo text-paper font-mono text-sm uppercase tracking-wider hover:bg-indigo-deep transition-colors"
93 >
94 {installLabel}
95 </Link>
96 </nav>
97 </div>
98 )}
99 </>
100 );
101 }
102
102 lines Plain Text