Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions frontend/src/__tests__/mobile-nav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Mobile navigation drawer tests
*
* Covers: hamburger open/close, overlay click, Escape key, sidebar link click,
* aria-expanded toggle, focus management, body scroll-lock class.
*/

import { setupMobileNav } from '../app';

function buildDOM(): void {
document.body.innerHTML = `
<button type="button" class="hamburger" id="hamburger-btn"
aria-label="Open navigation menu"
aria-controls="sidebar"
aria-expanded="false">
</button>
<div class="sidebar-overlay" id="sidebar-overlay" aria-hidden="true"></div>
<aside id="sidebar" aria-label="Primary navigation" aria-hidden="true">
<a class="tab-btn" href="/home" data-tab="home">Home</a>
<a class="tab-btn" href="/plans" data-tab="plans">Plans</a>
</aside>
`;
}

describe('setupMobileNav', () => {
beforeEach(() => {
buildDOM();
document.body.classList.remove('sidebar-open');
});

afterEach(() => {
document.body.innerHTML = '';
document.body.classList.remove('sidebar-open');
});

function hamburger(): HTMLButtonElement {
return document.getElementById('hamburger-btn') as HTMLButtonElement;
}
function sidebar(): HTMLElement {
return document.getElementById('sidebar') as HTMLElement;
}
function overlay(): HTMLElement {
return document.getElementById('sidebar-overlay') as HTMLElement;
}

test('hamburger click adds sidebar-open to body', () => {
setupMobileNav();
hamburger().click();
expect(document.body.classList.contains('sidebar-open')).toBe(true);
});

test('hamburger click sets aria-expanded to true on open', () => {
setupMobileNav();
hamburger().click();
expect(hamburger().getAttribute('aria-expanded')).toBe('true');
});

test('second hamburger click removes sidebar-open from body', () => {
setupMobileNav();
hamburger().click(); // open
hamburger().click(); // close
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('second hamburger click sets aria-expanded to false', () => {
setupMobileNav();
hamburger().click(); // open
hamburger().click(); // close
expect(hamburger().getAttribute('aria-expanded')).toBe('false');
});

test('overlay click closes the drawer', () => {
setupMobileNav();
hamburger().click(); // open
overlay().click(); // close via overlay
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('overlay click sets aria-expanded to false', () => {
setupMobileNav();
hamburger().click();
overlay().click();
expect(hamburger().getAttribute('aria-expanded')).toBe('false');
});

test('Escape key closes the drawer when open', () => {
setupMobileNav();
hamburger().click(); // open
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('Escape key does not throw when drawer is already closed', () => {
setupMobileNav();
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}).not.toThrow();
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('clicking a sidebar link closes the drawer', () => {
setupMobileNav();
hamburger().click(); // open
const firstLink = sidebar().querySelector<HTMLElement>('.tab-btn');
firstLink!.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('modifier-key click on sidebar link does not close the drawer', () => {
setupMobileNav();
hamburger().click(); // open
const firstLink = sidebar().querySelector<HTMLElement>('.tab-btn');
// Ctrl+click simulates "open in new tab" — should not close the drawer
firstLink!.dispatchEvent(new MouseEvent('click', { button: 0, ctrlKey: true, bubbles: true }));
expect(document.body.classList.contains('sidebar-open')).toBe(true);
});

test('sidebar aria-hidden is false when open, true when closed', () => {
setupMobileNav();
hamburger().click(); // open
expect(sidebar().getAttribute('aria-hidden')).toBe('false');
hamburger().click(); // close
expect(sidebar().getAttribute('aria-hidden')).toBe('true');
});

test('overlay aria-hidden is false when open, true when closed', () => {
setupMobileNav();
hamburger().click(); // open
expect(overlay().getAttribute('aria-hidden')).toBe('false');
overlay().click(); // close
expect(overlay().getAttribute('aria-hidden')).toBe('true');
});

test('focus moves to first sidebar link on open', () => {
setupMobileNav();
hamburger().click();
const firstLink = sidebar().querySelector<HTMLElement>('.tab-btn');
// jsdom doesn't implement layout so focus() is a no-op, but we can
// verify the call was attempted by checking that no error was thrown
// and the structure is correct
expect(firstLink).not.toBeNull();
});

test('no-op when hamburger element is missing from DOM', () => {
document.getElementById('hamburger-btn')!.remove();
expect(() => setupMobileNav()).not.toThrow();
});

test('no-op when sidebar element is missing from DOM', () => {
document.getElementById('sidebar')!.remove();
expect(() => setupMobileNav()).not.toThrow();
});
});
89 changes: 89 additions & 0 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ export function setupEventListeners(): void {
// Setup feedback link
setupFeedbackLink();

// Mobile navigation drawer (hamburger button below 768px)
setupMobileNav();

// Setup all button event listeners (replacing onclick handlers)
setupButtonHandlers();

Expand Down Expand Up @@ -624,6 +627,92 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
}
}

/**
* Wire the mobile navigation drawer (hamburger button + overlay + Escape).
*
* Activates below 768px (CSS hides the hamburger above that breakpoint).
*
* Open: body.classList.add('sidebar-open')
* hamburger aria-expanded="true"
* sidebar aria-hidden="false"
* Focus first focusable link in the sidebar
*
* Close: body.classList.remove('sidebar-open')
* hamburger aria-expanded="false"
* sidebar aria-hidden="true"
* Return focus to the hamburger button
*
* Sidebar links also close the drawer (they navigate within the SPA).
*/
export function setupMobileNav(): void {
const hamburger = document.getElementById('hamburger-btn') as HTMLButtonElement | null;
const sidebar = document.getElementById('sidebar') as HTMLElement | null;
const overlay = document.getElementById('sidebar-overlay') as HTMLElement | null;
if (!hamburger || !sidebar) return;

function openDrawer(): void {
document.body.classList.add('sidebar-open');
hamburger!.setAttribute('aria-expanded', 'true');
sidebar!.setAttribute('aria-hidden', 'false');
if (overlay) overlay.setAttribute('aria-hidden', 'false');

// Focus the first focusable element in the sidebar
const firstFocusable = sidebar!.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}

function closeDrawer(): void {
document.body.classList.remove('sidebar-open');
hamburger!.setAttribute('aria-expanded', 'false');
sidebar!.setAttribute('aria-hidden', 'true');
if (overlay) overlay.setAttribute('aria-hidden', 'true');
hamburger!.focus();
}

hamburger.addEventListener('click', () => {
const isOpen = document.body.classList.contains('sidebar-open');
if (isOpen) {
closeDrawer();
} else {
openDrawer();
}
});

// Overlay click closes the drawer
if (overlay) {
overlay.addEventListener('click', () => closeDrawer());
}

// Escape key closes the drawer
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
e.preventDefault();
closeDrawer();
}
});

// Clicking any sidebar link closes the drawer (SPA navigation)
sidebar.querySelectorAll<HTMLElement>('.tab-btn').forEach(link => {
link.addEventListener('click', (e: MouseEvent) => {
// Only close on unmodified left-click (same guard as tab switching above)
if (
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
if (document.body.classList.contains('sidebar-open')) {
closeDrawer();
}
});
});
}

/**
* Setup feedback mailto link with template
*/
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
<a href="#main-content" class="skip-to-main">Skip to main content</a>
<div id="app" class="app-shell">
<header class="app-topbar">
<!-- Mobile-only hamburger button. Toggles the sidebar drawer
below 768px via .sidebar-open on <body>. Hidden on desktop
via CSS. aria-controls points at the <aside id="sidebar">. -->
<button type="button" class="hamburger" id="hamburger-btn"
aria-label="Open navigation menu"
aria-controls="sidebar"
aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="app-topbar-brand">
<span class="app-logo-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>
Expand All @@ -38,8 +47,11 @@ <h1>CUDly</h1>
<button id="logout-btn">Logout</button>
</div>
</header>
<!-- Overlay: covers content when sidebar drawer is open on mobile.
Clicking it closes the drawer. Hidden above 768px via CSS. -->
<div class="sidebar-overlay" id="sidebar-overlay" aria-hidden="true"></div>
<div class="app-body">
<aside class="app-sidebar" aria-label="Primary navigation">
<aside class="app-sidebar" id="sidebar" aria-label="Primary navigation">
<button class="app-sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-icon"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/styles/responsive.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,53 @@
/* Responsive styles: Media queries */

/* ---------------------------------------------------------------------------
* Mobile navigation drawer (below 768px)
*
* Pattern: hamburger button in topbar toggles .sidebar-open on <body>.
* - .app-sidebar slides in from the left with a CSS transition.
* - .sidebar-overlay fades in to dim the content behind the drawer.
* - Body scroll is locked while the drawer is open.
* - The hamburger button itself is hidden above the breakpoint.
* - aria-expanded / aria-hidden toggled from app.ts:setupMobileNav().
* ------------------------------------------------------------------------- */

/* Hamburger button — mobile only */
.hamburger {
display: none; /* hidden above breakpoint */
background: transparent;
border: none;
color: var(--cudly-primary-fg);
padding: var(--cudly-sp-2);
margin-right: var(--cudly-sp-2);
cursor: pointer;
border-radius: var(--cudly-r-md);
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.hamburger:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.8);
outline-offset: 2px;
}

/* Overlay that dims the content behind the open sidebar drawer */
.sidebar-overlay {
display: none; /* hidden above breakpoint */
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}

/* Body scroll lock while drawer is open */
body.sidebar-open {
overflow: hidden;
}

@media (max-width: 768px) {
header {
flex-direction: column;
Expand Down Expand Up @@ -60,6 +109,56 @@
.plan-details {
grid-template-columns: 1fr 1fr;
}

/* Topbar: show hamburger, wrap filter chips to prevent overflow */
.hamburger {
display: inline-flex;
}

.app-topbar-filters {
flex-wrap: wrap;
row-gap: var(--cudly-sp-2);
}

/* Sidebar: off-screen by default; slides in when .sidebar-open is on <body> */
.app-sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 100;
/* Override the 900px icon-only collapse for mobile — full labels when open */
width: var(--cudly-sidebar-w);
transform: translateX(-100%);
transition: transform 0.25s ease;
/* Ensure the sidebar is rendered above the overlay */
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}

/* Restore labels hidden by the 900px collapse rule */
.app-sidebar .sidebar-label {
display: inline;
}

/* Hide the desktop collapse toggle inside the drawer — it is not
needed on mobile where the overlay / Escape / link-click close it. */
.app-sidebar-toggle {
display: none;
}

/* Overlay: visible on mobile, pointer events active only when open */
.sidebar-overlay {
display: block;
}

body.sidebar-open .sidebar-overlay {
opacity: 1;
pointer-events: auto;
}

body.sidebar-open .app-sidebar {
transform: translateX(0);
}
}

@media (max-width: 1200px) {
Expand Down
Loading