返回 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 { useCallback, useEffect, useRef, useState } from "react";
6 import { createPortal } from "react-dom";
7 import { currentNavHref, type ChromeLink } from "@/lib/i18n/links";
8
9 export function MobileMenu({
10 links,
11 moreLinks,
12 installHref,
13 installLabel,
14 signInHref,
15 signInLabel,
16 registerHref,
17 registerLabel,
18 openLabel,
19 closeLabel,
20 navAria,
21 }: {
22 links: ChromeLink[];
23 /** The second group on the sheet: start, install, FAQ, community, contribute. */
24 moreLinks: ChromeLink[];
25 installHref: string;
26 installLabel: string;
27 signInHref: string;
28 signInLabel: string;
29 registerHref: string;
30 registerLabel: string;
31 openLabel: string;
32 closeLabel: string;
33 /** Accessible name for the dialog's navigation landmark. */
34 navAria: string;
35 }) {
36 const [open, setOpen] = useState(false);
37 // `closing` holds the panel mounted for a short exit fade; the unmount —
38 // and the focus hand-back in the effect below — then happens on the
39 // timeout, not on the click.
40 const [closing, setClosing] = useState(false);
41 const closeTimer = useRef<number | null>(null);
42 const pathname = usePathname();
43 // One link is the page; ancestors are not. See currentNavHref. Both
44 // groups compete for the one current mark.
45 const currentHref = currentNavHref([...links, ...moreLinks], pathname);
46 const toggleRef = useRef<HTMLButtonElement>(null);
47 const menuRef = useRef<HTMLDivElement>(null);
48
49 const closeImmediately = useCallback(() => {
50 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
51 closeTimer.current = null;
52 setOpen(false);
53 setClosing(false);
54 }, []);
55
56 const close = useCallback(() => {
57 // Reduced motion keeps the original instant mount/unmount.
58 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
59 closeImmediately();
60 return;
61 }
62 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
63 setClosing(true);
64 closeTimer.current = window.setTimeout(() => {
65 closeTimer.current = null;
66 setOpen(false);
67 setClosing(false);
68 }, 180);
69 }, [closeImmediately]);
70
71 const onToggle = () => {
72 if (!open) {
73 setOpen(true);
74 return;
75 }
76 if (closing) {
77 // Re-open mid-exit: cancel the pending unmount and stay open.
78 window.clearTimeout(closeTimer.current ?? undefined);
79 closeTimer.current = null;
80 setClosing(false);
81 return;
82 }
83 close();
84 };
85
86 useEffect(() => {
87 if (!open) return;
88 const prev = document.body.style.overflow;
89 document.body.style.overflow = "hidden";
90 // aria-modal promises the dialog owns interaction. Keep background roots
91 // inert, contain keyboard focus, and hand it back to the toggle on close.
92 const dialog = menuRef.current;
93 const backgroundRoots = Array.from(document.body.children)
94 .filter((element): element is HTMLElement =>
95 element instanceof HTMLElement && element !== dialog)
96 .map((element) => ({ element, wasInert: element.inert }));
97 for (const { element } of backgroundRoots) element.inert = true;
98
99 // The toggle node is captured now: reading toggleRef.current inside the
100 // cleanup would race React clearing the ref.
101 const toggle = toggleRef.current;
102 const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>(
103 'a[href], button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
104 ) ?? []).filter((element) => element.getClientRects().length > 0);
105 focusable()[0]?.focus();
106
107 const onKey = (e: KeyboardEvent) => {
108 if (e.key === "Escape") {
109 e.preventDefault();
110 close();
111 return;
112 }
113 if (e.key !== "Tab") return;
114 const candidates = focusable();
115 const first = candidates[0];
116 const last = candidates[candidates.length - 1];
117 if (!first || !last) {
118 e.preventDefault();
119 return;
120 }
121 const active = document.activeElement;
122 if (e.shiftKey && (active === first || !dialog?.contains(active))) {
123 e.preventDefault();
124 last.focus();
125 } else if (!e.shiftKey && (active === last || !dialog?.contains(active))) {
126 e.preventDefault();
127 first.focus();
128 }
129 };
130
131 // Tailwind's xl boundary hides the compact controls. Close immediately
132 // when a live resize crosses it so an invisible sheet cannot retain the
133 // body's scroll lock.
134 const desktop = window.matchMedia("(min-width: 1280px)");
135 const onDesktop = (event: MediaQueryListEvent | MediaQueryList) => {
136 if (event.matches) closeImmediately();
137 };
138 window.addEventListener("keydown", onKey);
139 desktop.addEventListener("change", onDesktop);
140 if (desktop.matches) closeImmediately();
141
142 return () => {
143 document.body.style.overflow = prev;
144 window.removeEventListener("keydown", onKey);
145 desktop.removeEventListener("change", onDesktop);
146 for (const { element, wasInert } of backgroundRoots) element.inert = wasInert;
147 if (toggle?.getClientRects().length) toggle.focus();
148 };
149 }, [close, closeImmediately, open]);
150
151 // A pending exit timer must not outlive the component (locale switches
152 // remount the nav).
153 useEffect(() => {
154 return () => {
155 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
156 };
157 }, []);
158
159 return (
160 <>
161 <button
162 ref={toggleRef}
163 type="button"
164 onClick={onToggle}
165 className="xl: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"
166 aria-label={open ? closeLabel : openLabel}
167 aria-expanded={open}
168 aria-controls="mobile-menu"
169 >
170 {open ? (
171 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
172 <path d="M2 2L12 12M12 2L2 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
173 </svg>
174 ) : (
175 <svg width="16" height="12" viewBox="0 0 16 12" fill="none" aria-hidden>
176 <path d="M0 1H16M0 6H16M0 11H16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
177 </svg>
178 )}
179 </button>
180
181 {open && typeof document !== "undefined" &&
182 createPortal(<div
183 ref={menuRef}
184 id="mobile-menu"
185 className={`mm-panel xl:hidden fixed inset-0 z-40 bg-paper overflow-y-auto${closing ? " mm-closing" : ""}`}
186 role="dialog"
187 aria-modal="true"
188 aria-label={navAria}
189 >
190 <div className="flex min-h-[5.75rem] items-center justify-between px-6 hairline-b">
191 <span className="font-display text-lg">{navAria}</span>
192 <button
193 type="button"
194 onClick={close}
195 className="inline-flex h-9 w-9 items-center justify-center hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
196 aria-label={closeLabel}
197 >
198 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
199 <path d="M2 2L12 12M12 2L2 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
200 </svg>
201 </button>
202 </div>
203 {/* Only one nav landmark is exposed at a time (the desktop nav is
204 display:none at these widths), so the named dialog carries the
205 landmark name and the inner nav stays unlabeled — two nested
206 "Primary" landmarks would read as duplication. */}
207 <nav className="px-6 py-4">
208 <ul className="divide-y divide-[rgba(20,35,82,0.14)]">
209 {[...links, ...moreLinks].map((l) => {
210 const isActive = l.href === currentHref;
211 return (
212 <li key={l.href}>
213 <Link
214 href={l.href}
215 onClick={() => setOpen(false)}
216 className={`flex items-baseline gap-3 py-4 hover:text-indigo transition-colors ${isActive ? "text-indigo" : ""}`}
217 aria-current={isActive ? "page" : undefined}
218 >
219 <span className="font-display text-lg">{l.label}</span>
220 {l.secondary && (
221 <span className="font-cjk text-sm text-ink-mute">{l.secondary}</span>
222 )}
223 <span className="ml-auto font-mono text-xs text-ink-mute">→</span>
224 </Link>
225 </li>
226 );
227 })}
228 </ul>
229
230 <Link
231 href={installHref}
232 onClick={() => setOpen(false)}
233 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"
234 >
235 {installLabel}
236 </Link>
237 <div className="mt-3 grid grid-cols-2 gap-3">
238 <Link
239 href={signInHref}
240 data-usage="login"
241 onClick={() => setOpen(false)}
242 className="block text-center px-5 py-3 hairline-t hairline-b hairline-l hairline-r font-mono text-sm uppercase tracking-wider hover:bg-paper-deep transition-colors"
243 >
244 {signInLabel}
245 </Link>
246 <Link
247 href={registerHref}
248 data-usage="signup"
249 onClick={() => setOpen(false)}
250 className="block text-center px-5 py-3 hairline-t hairline-b hairline-l hairline-r font-mono text-sm uppercase tracking-wider text-indigo hover:bg-paper-deep transition-colors"
251 >
252 {registerLabel}
253 </Link>
254 </div>
255 </nav>
256 </div>, document.body)}
257 </>
258 );
259 }
260
260 lines Plain Text