From 95aa9bf8b1f1335970182741b7302d6b86aad7bd Mon Sep 17 00:00:00 2001 From: Fi-zzy <332114773+Fi-zzy@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:33:38 +0100 Subject: [PATCH] fix(frontend): trap focus in mobile navigation drawer (#456) --- frontend/components/Navbar.test.tsx | 44 +++++++++++++++++- frontend/components/Navbar.tsx | 69 ++++++++++++++++++----------- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/frontend/components/Navbar.test.tsx b/frontend/components/Navbar.test.tsx index f9a98a16..56299bd4 100644 --- a/frontend/components/Navbar.test.tsx +++ b/frontend/components/Navbar.test.tsx @@ -1,8 +1,11 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { axe, toHaveNoViolations } from "jest-axe"; import Navbar from "./Navbar"; +expect.extend(toHaveNoViolations); + let currentPathname = "/"; jest.mock("next/navigation", () => ({ @@ -117,4 +120,43 @@ describe("Navbar Active Route Indicator (Issue #327)", () => { const drawer = screen.getByRole("dialog", { name: "Navigation menu" }); expect(drawer.className).toContain("translate-x-0"); }); + + it("keeps keyboard focus within the open drawer and restores it on Escape", () => { + render(); + const trigger = screen.getByRole("button", { name: "Open menu" }); + fireEvent.click(trigger); + + const drawer = screen.getByRole("dialog", { name: "Navigation menu" }); + expect(within(drawer).getByRole("link", { name: "Marketplace" })).toHaveFocus(); + + const focusable = Array.from( + drawer.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])') + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + first?.focus(); + fireEvent.keyDown(first as HTMLElement, { key: "Tab", shiftKey: true }); + expect(last).toHaveFocus(); + + last?.focus(); + fireEvent.keyDown(last as HTMLElement, { key: "Tab" }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(drawer, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Navigation menu" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it("has no axe accessibility violations when the navigation drawer is open", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("keeps the closed navigation drawer out of the accessibility tree", async () => { + const { container } = render(); + + expect(screen.queryByRole("dialog", { name: "Navigation menu" })).not.toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); }); diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 007d62db..365d6be2 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -19,7 +19,7 @@ * a single `` import — no props required. */ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, type KeyboardEvent } from "react"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { getUser, clearToken, isAuthenticated } from "../app/lib/auth"; @@ -213,28 +213,44 @@ export default function Navbar({ setDrawerOpen(false); }, [pathname]); - // ---- Escape key closes drawer ------------------------------------------- - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape" && drawerOpen) { - setDrawerOpen(false); - hamburgerRef.current?.focus(); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [drawerOpen]); - - // ---- trap focus inside drawer ------------------------------------------- + // ---- focus the first navigation item when the drawer opens --------------- useEffect(() => { if (!drawerOpen) return; - // Focus the first focusable element inside the drawer - const first = drawerRef.current?.querySelector( - 'a, button, [tabindex]:not([tabindex="-1"])' - ); - first?.focus(); + drawerRef.current?.querySelector('nav a[href]')?.focus(); }, [drawerOpen]); + function closeDrawer() { + setDrawerOpen(false); + hamburgerRef.current?.focus(); + } + + function handleDrawerKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + closeDrawer(); + return; + } + + if (event.key !== "Tab" || !drawerRef.current) return; + + const focusable = Array.from( + drawerRef.current.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((element) => element.getAttribute("aria-hidden") !== "true"); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && (active === first || !drawerRef.current.contains(active))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (active === last || !drawerRef.current.contains(active))) { + event.preventDefault(); + first?.focus(); + } + } + // ---- logout ------------------------------------------------------------- function handleLogout() { clearToken(); @@ -383,19 +399,18 @@ export default function Navbar({ } ); }