From 43a863740a41aad09721bb5166c6e468481fa799 Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:13:32 +0000 Subject: [PATCH] Add compact mobile header with hamburger menu and slide-in drawer (#218) --- src/components/Navbar.css | 132 +++++++++++++++++++++++++++++- src/components/Navbar.jsx | 86 +++++++++++++++++-- src/components/Navbar.stories.jsx | 2 +- src/responsive.css | 5 -- test/components/Navbar.test.jsx | 98 ++++++++++++++++++++++ test/touch-targets.test.js | 9 ++ 6 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 test/components/Navbar.test.jsx diff --git a/src/components/Navbar.css b/src/components/Navbar.css index 5a119c1..0c3620e 100644 --- a/src/components/Navbar.css +++ b/src/components/Navbar.css @@ -51,8 +51,138 @@ margin-left: auto; } +/* ── Hamburger button ─────────────────────────── */ + +.navbar-hamburger { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + margin-left: auto; + background: none; + border: none; + cursor: pointer; + min-width: 44px; + min-height: 44px; + padding: 8px; + border-radius: 6px; + color: var(--color-text); +} + +.navbar-hamburger:hover { + background: rgba(255, 255, 255, 0.06); +} + +.navbar-hamburger-line { + display: block; + width: 22px; + height: 2px; + background: currentColor; + border-radius: 2px; + transition: transform 0.2s ease; +} + +/* ── Mobile backdrop ─────────────────────────── */ + +.navbar-backdrop { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(0, 0, 0, 0.5); +} + +/* ── Mobile drawer ─────────────────────────── */ + +.navbar-drawer { + position: fixed; + top: 0; + right: 0; + z-index: 100; + width: 280px; + height: 100vh; + background: var(--color-surface); + border-left: 1px solid #1f2a3a; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.25s ease; +} + +.navbar-drawer--open { + transform: translateX(0); +} + +.navbar-drawer-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem 0.75rem; + flex: 1; +} + +.navbar-drawer-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 8px; + color: var(--color-muted); + font-weight: 500; + font-size: 0.925rem; + text-decoration: none; + min-height: 44px; + transition: background 0.15s ease, color 0.15s ease; +} + +.navbar-drawer-link:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--color-text); +} + +.navbar-drawer-link.active { + background: rgba(99, 102, 241, 0.15); + color: var(--color-primary); +} + +.navbar-drawer-link-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; +} + +.navbar-drawer-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 0.75rem; + border-top: 1px solid #1f2a3a; +} + +/* ── Mobile responsive ─────────────────────────── */ + +@media (max-width: 720px) { + .navbar-links { + display: none; + } + + .navbar-actions { + display: none; + } + + .navbar-hamburger { + display: flex; + } +} + @media (prefers-contrast: more) { .navbar { border-bottom-width: 2px; } -} + + .navbar-drawer { + border-left-width: 2px; + } +} \ No newline at end of file diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 2e77254..f9c0e10 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,15 +1,31 @@ +import { useState, useEffect, useCallback } from 'react' import { NavLink, Link } from 'react-router-dom' import WalletButton from './WalletButton.jsx' import LocaleSelect from './LocaleSelect.jsx' import { useApp } from '../context/AppContext.jsx' import './Navbar.css' -/** - * Top navigation bar with links, the locale preference and the wallet - * connect button. - */ export default function Navbar() { const { locale, setLocale } = useApp() + const [menuOpen, setMenuOpen] = useState(false) + + const closeMenu = useCallback(() => setMenuOpen(false), []) + + useEffect(() => { + if (!menuOpen) return + const handler = (e) => { if (e.key === 'Escape') closeMenu() } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [menuOpen, closeMenu]) + + useEffect(() => { + if (menuOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { document.body.style.overflow = '' } + }, [menuOpen]) return (
@@ -39,6 +55,66 @@ export default function Navbar() { /> + + + + {menuOpen && ( +
+ )} + +
) -} +} \ No newline at end of file diff --git a/src/components/Navbar.stories.jsx b/src/components/Navbar.stories.jsx index 1e9178f..190200b 100644 --- a/src/components/Navbar.stories.jsx +++ b/src/components/Navbar.stories.jsx @@ -16,4 +16,4 @@ export default { export const Default = { render: () => -} +} \ No newline at end of file diff --git a/src/responsive.css b/src/responsive.css index f83cd4c..9057e8e 100644 --- a/src/responsive.css +++ b/src/responsive.css @@ -1,11 +1,6 @@ /* Responsive tweaks for narrow viewports. */ @media (max-width: 720px) { - .navbar { - flex-wrap: wrap; - gap: 0.75rem; - } - .features { grid-template-columns: 1fr; } diff --git a/test/components/Navbar.test.jsx b/test/components/Navbar.test.jsx new file mode 100644 index 0000000..f3bbb65 --- /dev/null +++ b/test/components/Navbar.test.jsx @@ -0,0 +1,98 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import Navbar from '../../src/components/Navbar.jsx' +import { AppProvider } from '../../src/context/AppContext.jsx' + +function renderNavbar(initialRoute = '/') { + return render( + + + + + + ) +} + +describe('Navbar', () => { + it('renders the brand name', () => { + renderNavbar() + expect(screen.getByText('RemitFlow')).toBeInTheDocument() + }) + + it('renders navigation links', () => { + renderNavbar() + const links = screen.getAllByText('Home') + expect(links.length).toBe(2) + expect(screen.getAllByText('Send Money').length).toBe(2) + expect(screen.getAllByText('Transfers').length).toBe(2) + }) + + it('highlights the active link based on current route', () => { + renderNavbar('/send') + const sendLinks = screen.getAllByText('Send Money') + const activeLinks = sendLinks.filter((l) => l.className.includes('active')) + expect(activeLinks.length).toBe(2) + }) + + it('has a hamburger button with aria-label', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + expect(hamburger).toBeInTheDocument() + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + }) + + it('opens the mobile drawer when hamburger is clicked', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + fireEvent.click(hamburger) + expect(screen.getByLabelText('Close menu')).toBeInTheDocument() + expect(hamburger.getAttribute('aria-expanded')).toBe('true') + const drawer = document.querySelector('.navbar-drawer') + expect(drawer).toHaveClass('navbar-drawer--open') + }) + + it('closes the mobile drawer when a nav link is clicked', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + fireEvent.click(hamburger) + + const homeLink = screen.getAllByText('Home')[1] + fireEvent.click(homeLink) + + expect(screen.getByLabelText('Open menu')).toBeInTheDocument() + const drawer = document.querySelector('.navbar-drawer') + expect(drawer).not.toHaveClass('navbar-drawer--open') + }) + + it('closes the mobile drawer on Escape key', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + fireEvent.click(hamburger) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(screen.getByLabelText('Open menu')).toBeInTheDocument() + }) + + it('closes the mobile drawer when backdrop is clicked', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + fireEvent.click(hamburger) + + const backdrop = document.querySelector('.navbar-backdrop') + fireEvent.click(backdrop) + + expect(screen.getByLabelText('Open menu')).toBeInTheDocument() + }) + + it('renders the LocaleSelect and WalletButton in the drawer on mobile', () => { + renderNavbar() + const hamburger = screen.getByLabelText('Open menu') + fireEvent.click(hamburger) + + const localeSelects = screen.getAllByLabelText('Language & region') + expect(localeSelects.length).toBe(2) + expect(screen.getAllByText('Connect Wallet').length).toBe(2) + }) +}) \ No newline at end of file diff --git a/test/touch-targets.test.js b/test/touch-targets.test.js index aa35a57..4e697eb 100644 --- a/test/touch-targets.test.js +++ b/test/touch-targets.test.js @@ -87,6 +87,15 @@ describe('touch target minimum sizes (44px)', () => { it('.navbar-link has min-height 44px', () => { expect(css).toMatch(/\.navbar-link\s*\{[^}]*min-height:\s*44px/); }); + + it('.navbar-hamburger has min-height and min-width 44px', () => { + expect(css).toMatch(/\.navbar-hamburger\s*\{[^}]*min-width:\s*44px/); + expect(css).toMatch(/\.navbar-hamburger\s*\{[^}]*min-height:\s*44px/); + }); + + it('.navbar-drawer-link has min-height 44px', () => { + expect(css).toMatch(/\.navbar-drawer-link\s*\{[^}]*min-height:\s*44px/); + }); }); describe('Footer', () => {