Skip to content

Commit 3fe9bc0

Browse files
committed
Improve mobile menu accessibility and dismissal behavior
Add focus management, semantic labeling, outside-click handling, and automatic closure after navigation or search selection. Increase sheet stacking and close-button touch targets, and cover the behavior with an end-to-end test.
1 parent 6d5b0bf commit 3fe9bc0

3 files changed

Lines changed: 51 additions & 13 deletions

File tree

components/Header.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Image from "next/image";
55
import menuLinks from "@/data/menu";
66
import SocialSharing from "./SocialSharing";
77
import Search, { SearchItem } from "./Search";
8-
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet";
8+
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "./ui/sheet";
99
import {
1010
RiInstagramLine,
1111
RiYoutubeFill,
@@ -15,7 +15,7 @@ import {
1515
RiUser3Line,
1616
} from "react-icons/ri";
1717

18-
import { useState, useEffect } from "react";
18+
import { useEffect, useRef, useState } from "react";
1919
import { collection, getDocs, query, where } from "firebase/firestore";
2020
import { db } from "@/lib/firebase";
2121
import { SITE_NAME } from "@/lib/seo";
@@ -24,6 +24,8 @@ import NotificationBell from "./NotificationBell";
2424

2525
export default function Header() {
2626
const [allArticles, setAllArticles] = useState<SearchItem[]>([]);
27+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
28+
const mobileNavRef = useRef<HTMLElement>(null);
2729
const { user, profile } = usePublicAuth();
2830
const accountPhotoURL = profile?.photoURL || user?.photoURL || "";
2931
const accountLabel = user ? "Open your profile" : "Sign in or create an account";
@@ -83,7 +85,7 @@ export default function Header() {
8385
</div>
8486

8587
{/* Mobile menu */}
86-
<Sheet>
88+
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
8789
<SheetTrigger
8890
aria-labelledby="button-label"
8991
className="relative z-10 md:hidden"
@@ -106,18 +108,34 @@ export default function Header() {
106108
</SheetTrigger>
107109
<SheetContent
108110
side="top"
109-
className="w-full pt-14"
110-
aria-label="Menu Toggle"
111+
className="max-h-[100dvh] w-full overflow-y-auto border-b border-white/30 bg-[#121212] px-4 pb-8 pt-16 text-white shadow-[0_24px_64px_rgba(0,0,0,0.55)] sm:px-6"
112+
aria-describedby={undefined}
113+
onOpenAutoFocus={(event) => {
114+
event.preventDefault();
115+
mobileNavRef.current?.focus({ preventScroll: true });
116+
}}
117+
onPointerDownOutside={() => setIsMobileMenuOpen(false)}
111118
>
119+
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
112120
<nav
113-
className="flex flex-col flex-1 justify-end gap-6"
114-
aria-labelledby="mobile-nav"
121+
ref={mobileNavRef}
122+
tabIndex={-1}
123+
className="flex flex-1 flex-col justify-end gap-6 focus:outline-none"
124+
aria-label="Primary navigation"
115125
>
116126
{/* Mobile Search */}
117-
<Search articles={allArticles} className="w-full" />
127+
<Search
128+
articles={allArticles}
129+
className="w-full"
130+
onSearchSelect={() => setIsMobileMenuOpen(false)}
131+
/>
118132

119133
{menuLinks.map((m, i) => (
120-
<Link key={i} href={m.href}>
134+
<Link
135+
key={i}
136+
href={m.href}
137+
onClick={() => setIsMobileMenuOpen(false)}
138+
>
121139
{m.label}
122140
</Link>
123141
))}
@@ -126,6 +144,7 @@ export default function Header() {
126144
<Link
127145
href="/account"
128146
aria-label={accountLabel}
147+
onClick={() => setIsMobileMenuOpen(false)}
129148
className="inline-flex items-center justify-center text-2xl text-white transition-colors duration-300 hover:text-[#8a2ae3]"
130149
>
131150
{accountPhotoURL ? (

components/ui/sheet.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const SheetOverlay = React.forwardRef<
2424
>(({ className, ...props }, ref) => (
2525
<SheetPrimitive.Overlay
2626
className={cn(
27-
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
27+
"fixed inset-0 z-[90] bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
2828
className
2929
)}
3030
{...props}
@@ -34,7 +34,7 @@ const SheetOverlay = React.forwardRef<
3434
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
3535

3636
const sheetVariants = cva(
37-
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
37+
"fixed z-[100] gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
3838
{
3939
variants: {
4040
side: {
@@ -68,8 +68,8 @@ const SheetContent = React.forwardRef<
6868
{...props}
6969
>
7070
{children}
71-
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
72-
<X className="h-4 w-4" />
71+
<SheetPrimitive.Close className="absolute right-3 top-3 inline-flex h-11 w-11 touch-manipulation items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary sm:right-4 sm:top-4">
72+
<X className="h-5 w-5" />
7373
<span className="sr-only">Close</span>
7474
</SheetPrimitive.Close>
7575
</SheetPrimitive.Content>

e2e/navigation.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,22 @@ test("should navigate from home page to posts page using header", async ({
1414
.click();
1515
await expect(page).toHaveURL("/posts");
1616
});
17+
18+
test("mobile menu opens cleanly and closes on an outside tap", async ({
19+
page,
20+
}) => {
21+
await page.setViewportSize({ width: 810, height: 1080 });
22+
await page.goto("/account");
23+
24+
await page.getByRole("button", { name: "Menu" }).click();
25+
26+
const menu = page.getByRole("dialog", { name: "Navigation menu" });
27+
await expect(menu).toBeVisible();
28+
await expect(menu.getByPlaceholder("Search...")).not.toBeFocused();
29+
30+
const menuBox = await menu.boundingBox();
31+
expect(menuBox).not.toBeNull();
32+
await page.mouse.click(8, Math.min(1070, menuBox!.y + menuBox!.height + 24));
33+
34+
await expect(menu).toBeHidden();
35+
});

0 commit comments

Comments
 (0)