From bf3a7ee51607ef192e14a8014bb788525b4e3313 Mon Sep 17 00:00:00 2001 From: waterWang Date: Mon, 3 Aug 2026 00:18:11 +0800 Subject: [PATCH] fix: add focus trap and accessibility tests for mobile menu (Closes #512) --- .../landing/components/Navbar.test.tsx | 42 ++++++++++++++++++- src/features/landing/components/Navbar.tsx | 9 +++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/features/landing/components/Navbar.test.tsx b/src/features/landing/components/Navbar.test.tsx index 56c96127..09cec8a4 100644 --- a/src/features/landing/components/Navbar.test.tsx +++ b/src/features/landing/components/Navbar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import { I18nProvider } from '../../../shared/i18n' @@ -81,3 +81,43 @@ describe('Navbar i18n strings', () => { expect(screen.getAllByText('Sign Out').length).toBeGreaterThanOrEqual(2) }) }) + +describe('Navbar mobile menu focus trap', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + it('moves focus into the mobile menu when opened', async () => { + const user = userEvent.setup() + mockUseAuth.mockReturnValue({ isAuthenticated: false, logout: vi.fn() }) + renderNavbar() + + await user.click(screen.getByLabelText('Open menu')) + + const mobileMenu = document.getElementById('mobile-menu') + expect(mobileMenu).toBeInTheDocument() + // The first focusable element inside the menu should receive focus. + expect(mobileMenu?.contains(document.activeElement)).toBe(true) + }) + + it('closes the mobile menu when Escape is pressed and returns focus to the toggle', async () => { + const user = userEvent.setup() + mockUseAuth.mockReturnValue({ isAuthenticated: false, logout: vi.fn() }) + renderNavbar() + + const toggle = screen.getByLabelText('Open menu') + await user.click(toggle) + + // Mobile menu should be visible. + expect(screen.getByLabelText('Close menu')).toBeInTheDocument() + + // Press Escape to close the menu. + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) + + // Menu should close and the toggle button should regain focus. + expect(screen.getByLabelText('Open menu')).toBeInTheDocument() + // The toggle button should have focus (returnFocus from useFocusTrap). + expect(screen.getByLabelText('Open menu')).toHaveFocus() + }) +}) diff --git a/src/features/landing/components/Navbar.tsx b/src/features/landing/components/Navbar.tsx index 2a41a845..436341e2 100644 --- a/src/features/landing/components/Navbar.tsx +++ b/src/features/landing/components/Navbar.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { useModeAnimation } from 'react-theme-switch-animation' import { useTheme } from '../../../shared/contexts/ThemeContext' import { useAuth } from '../../../shared/contexts/AuthContext' +import { useFocusTrap } from '../../../shared/utils/focusTrap' import grainlifyLogo from '../../../assets/grainlify_log.svg' export function Navbar() { @@ -15,6 +16,12 @@ export function Navbar() { isDarkMode: theme === 'dark', onDarkModeChange: (isDark) => setThemeFromAnimation(isDark), }) + const mobileMenuRef = useFocusTrap(mobileMenuOpen, { + onEscape: () => { + setMobileMenuOpen(false) + // Focus returns to the hamburger toggle automatically via useFocusTrap's returnFocus + }, + }) return (