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', () => {