返回 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 import { Icon } from "./icon";
9 import { ArrowLabel } from "./arrow-label";
10
11 export function MobileMenu({
12 links,
13 moreLinks,
14 installHref,
15 installLabel,
16 signInHref,
17 signInLabel,
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 openLabel: string;
30 closeLabel: string;
31 /** Accessible name for the dialog's navigation landmark. */
32 navAria: string;
33 }) {
34 const [open, setOpen] = useState(false);
35 // `closing` holds the panel mounted for a short exit fade; the unmount —
36 // and the focus hand-back in the effect below — then happens on the
37 // timeout, not on the click.
38 const [closing, setClosing] = useState(false);
39 const closeTimer = useRef<number | null>(null);
40 const pathname = usePathname();
41 // One link is the page; ancestors are not. See currentNavHref. Both
42 // groups compete for the one current mark.
43 const currentHref = currentNavHref([...links, ...moreLinks], pathname);
44 const toggleRef = useRef<HTMLButtonElement>(null);
45 const menuRef = useRef<HTMLDivElement>(null);
46
47 const closeImmediately = useCallback(() => {
48 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
49 closeTimer.current = null;
50 setOpen(false);
51 setClosing(false);
52 }, []);
53
54 const close = useCallback(() => {
55 // Reduced motion keeps the original instant mount/unmount.
56 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
57 closeImmediately();
58 return;
59 }
60 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
61 setClosing(true);
62 closeTimer.current = window.setTimeout(() => {
63 closeTimer.current = null;
64 setOpen(false);
65 setClosing(false);
66 }, 180);
67 }, [closeImmediately]);
68
69 const onToggle = () => {
70 if (!open) {
71 setOpen(true);
72 return;
73 }
74 if (closing) {
75 // Re-open mid-exit: cancel the pending unmount and stay open.
76 window.clearTimeout(closeTimer.current ?? undefined);
77 closeTimer.current = null;
78 setClosing(false);
79 return;
80 }
81 close();
82 };
83
84 useEffect(() => {
85 if (!open) return;
86 const prev = document.body.style.overflow;
87 document.body.style.overflow = "hidden";
88 // aria-modal promises the dialog owns interaction. Keep background roots
89 // inert, contain keyboard focus, and hand it back to the toggle on close.
90 const dialog = menuRef.current;
91 const backgroundRoots = Array.from(document.body.children)
92 .filter((element): element is HTMLElement =>
93 element instanceof HTMLElement && element !== dialog)
94 .map((element) => ({ element, wasInert: element.inert }));
95 for (const { element } of backgroundRoots) element.inert = true;
96
97 // The toggle node is captured now: reading toggleRef.current inside the
98 // cleanup would race React clearing the ref.
99 const toggle = toggleRef.current;
100 const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>(
101 'a[href], button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
102 ) ?? []).filter((element) => element.getClientRects().length > 0);
103 focusable()[0]?.focus();
104
105 const onKey = (e: KeyboardEvent) => {
106 if (e.key === "Escape") {
107 e.preventDefault();
108 close();
109 return;
110 }
111 if (e.key !== "Tab") return;
112 const candidates = focusable();
113 const first = candidates[0];
114 const last = candidates[candidates.length - 1];
115 if (!first || !last) {
116 e.preventDefault();
117 return;
118 }
119 const active = document.activeElement;
120 if (e.shiftKey && (active === first || !dialog?.contains(active))) {
121 e.preventDefault();
122 last.focus();
123 } else if (!e.shiftKey && (active === last || !dialog?.contains(active))) {
124 e.preventDefault();
125 first.focus();
126 }
127 };
128
129 // Tailwind's lg boundary hides the compact controls. Close immediately
130 // when a live resize crosses it so an invisible sheet cannot retain the
131 // body's scroll lock.
132 const desktop = window.matchMedia("(min-width: 1024px)");
133 const onDesktop = (event: MediaQueryListEvent | MediaQueryList) => {
134 if (event.matches) closeImmediately();
135 };
136 window.addEventListener("keydown", onKey);
137 desktop.addEventListener("change", onDesktop);
138 if (desktop.matches) closeImmediately();
139
140 return () => {
141 document.body.style.overflow = prev;
142 window.removeEventListener("keydown", onKey);
143 desktop.removeEventListener("change", onDesktop);
144 for (const { element, wasInert } of backgroundRoots) element.inert = wasInert;
145 if (toggle?.getClientRects().length) toggle.focus();
146 };
147 }, [close, closeImmediately, open]);
148
149 // A pending exit timer must not outlive the component (locale switches
150 // remount the nav).
151 useEffect(() => {
152 return () => {
153 if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
154 };
155 }, []);
156
157 return (
158 <>
159 <button
160 ref={toggleRef}
161 type="button"
162 onClick={onToggle}
163 className="nav-icon-button lg:hidden"
164 aria-label={open ? closeLabel : openLabel}
165 aria-expanded={open}
166 aria-controls="mobile-menu"
167 >
168 <Icon name={open ? "x" : "menu"} className="nav-icon" />
169 </button>
170
171 {open && typeof document !== "undefined" &&
172 createPortal(<div
173 ref={menuRef}
174 id="mobile-menu"
175 className={`mm-panel lg:hidden fixed inset-0 z-40 bg-canvas overflow-y-auto${closing ? " mm-closing" : ""}`}
176 role="dialog"
177 aria-modal="true"
178 aria-label={navAria}
179 >
180 <div className="flex min-h-[3.85rem] items-center justify-end px-4 hairline-b">
181 <button type="button" onClick={close} className="nav-icon-button" aria-label={closeLabel}>
182 <Icon name="x" className="nav-icon" />
183 </button>
184 </div>
185 {/* Only one nav landmark is exposed at a time (the desktop nav is
186 display:none at these widths), so the named dialog carries the
187 landmark name and the inner nav stays unlabeled — two nested
188 "Primary" landmarks would read as duplication. */}
189 <nav className="px-6 py-4">
190 <ul className="mm-list">
191 {[...links, ...moreLinks].map((l) => {
192 const isActive = l.href === currentHref;
193 return (
194 <li key={l.href}>
195 <Link
196 href={l.href}
197 onClick={() => setOpen(false)}
198 className="mm-link"
199 aria-current={isActive ? "page" : undefined}
200 >
201 <span>{l.label}</span>
202 <Icon name="chevron-right" className="nav-icon mm-link-chevron icon-flip" />
203 </Link>
204 </li>
205 );
206 })}
207 </ul>
208
209 <Link
210 href={installHref}
211 onClick={() => setOpen(false)}
212 className="mm-action paper-install-cta"
213 >
214 <ArrowLabel text={installLabel} />
215 </Link>
216 <Link
217 href={signInHref}
218 data-usage="login"
219 onClick={() => setOpen(false)}
220 className="mm-action paper-auth-signin"
221 >
222 {signInLabel}
223 </Link>
224 </nav>
225 </div>, document.body)}
226 </>
227 );
228 }
229
229 lines Plain Text