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
25 changes: 24 additions & 1 deletion frontend/src/__tests__/mobile-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* aria-expanded toggle, focus management, body scroll-lock class.
*/

import { setupMobileNav, syncHeaderPlacement } from '../app';
import { setupMobileNav, syncHeaderPlacement } from '../nav/mobile-nav';

function buildDOM(): void {
document.body.innerHTML = `
Expand Down Expand Up @@ -165,6 +165,29 @@ describe('setupMobileNav', () => {
document.getElementById('sidebar')!.remove();
expect(() => setupMobileNav()).not.toThrow();
});

test('returns null when there is nothing to wire', () => {
document.getElementById('hamburger-btn')!.remove();
expect(setupMobileNav()).toBeNull();
});

test('returned handle drives the same open/close transitions as the hamburger', () => {
const nav = setupMobileNav();
expect(nav).not.toBeNull();
expect(nav!.isOpen()).toBe(false);

nav!.open();
expect(nav!.isOpen()).toBe(true);
expect(hamburger().getAttribute('aria-expanded')).toBe('true');
expect(sidebar().hasAttribute('inert')).toBe(false);
expect(document.activeElement).toBe(sidebar().querySelector('.tab-btn'));

nav!.close();
expect(nav!.isOpen()).toBe(false);
expect(hamburger().getAttribute('aria-expanded')).toBe('false');
expect(sidebar().hasAttribute('inert')).toBe(true);
expect(document.activeElement).toBe(hamburger());
});
});

/**
Expand Down
204 changes: 1 addition & 203 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleRIExchangeDeeplink } from './riexchange-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
import { setupMobileNav } from './nav/mobile-nav';
import { initPurchasingNav } from './purchasing-nav';
import { renderInitError } from './init-error';

Expand Down Expand Up @@ -810,209 +811,6 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
await loadDashboard();
}

/**
* Drawer breakpoint. Must stay in lockstep with the `max-width: 768px` block
* in styles/responsive.css, which lays these same regions out of the topbar.
*/
const MOBILE_NAV_QUERY = '(max-width: 768px)';

/**
* Topbar regions with no room in the topbar below the breakpoint, listed in
* the order they appear in the header so restoring them re-appends correctly.
*/
const DRAWER_PROJECTED_IDS = ['topbar-filters', 'user-info'] as const;

/**
* Move the global filter chips and the account actions between the topbar
* and the drawer (issue #1779). Relocation rather than duplication: the
* chips and the logout/profile handlers are bound to these exact nodes, and
* moving a node keeps its listeners and state.
*
* Idempotent, and a no-op when either endpoint is absent.
*/
export function syncHeaderPlacement(isNarrow: boolean): void {
const topbar = document.querySelector<HTMLElement>('.app-topbar');
const extras = document.getElementById('sidebar-extras');
if (!topbar || !extras) return;

const target = isNarrow ? extras : topbar;
const elements = DRAWER_PROJECTED_IDS
.map(id => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);

// Re-append all of them whenever any one is misplaced, so the restored
// header keeps DRAWER_PROJECTED_IDS order. Appending an element that is
// already in place would tear its subtree out and back in, dropping focus
// and closing an open chip popover, so the whole pass is skipped instead.
const misplaced = elements.some(el => el.parentElement !== target);
if (!misplaced) return;
for (const el of elements) target.appendChild(el);
}

/**
* 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", inert cleared
* Focus first focusable link in the sidebar
*
* Close: body.classList.remove('sidebar-open')
* hamburger aria-expanded="false"
* sidebar aria-hidden="true", inert set
* Return focus to the hamburger button
*
* A closed drawer needs both attributes and they always move together.
* aria-hidden takes it out of the accessibility tree; inert takes it out of
* sequential keyboard navigation, which neither aria-hidden nor the
* off-screen transform affects. With only the first, tabbing walks into
* controls that are invisible and expose no accessible name.
*
* Crossing the breakpoint in either direction is a third transition, neither
* an open nor a close: see applyBreakpointState.
*
* 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');
// Before the focus below, not after: focus into an inert subtree is
// dropped silently and the caret would land on <body>.
sidebar!.removeAttribute('inert');
if (overlay) overlay.setAttribute('aria-hidden', 'false');

// The desktop collapse toggle is display:none below the drawer
// breakpoint, where focus() on it is a no-op, so skip it.
const firstFocusable = sidebar!.querySelector<HTMLElement>(
'a[href], button:not([disabled]):not(.app-sidebar-toggle), [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');
sidebar!.setAttribute('inert', '');
if (overlay) overlay.setAttribute('aria-hidden', 'true');
hamburger!.focus();
}

/**
* Put the drawer into the closed state the current breakpoint calls for.
* Runs on load and on every crossing, neither of which the user asked for,
* so unlike openDrawer and closeDrawer it moves focus only to rescue it.
*
* Whether the sidebar is reachable at all is what the two widths disagree
* about. Above the breakpoint it is the permanently visible navigation and
* must stay both in the accessibility tree and in the tab order; below, it
* is a drawer parked off-screen holding the controls syncHeaderPlacement
* moves into it, and everything in there has to leave both with it.
*
* The narrow branch can never be hiding an open drawer: the only crossing
* that reaches it comes from the desktop side, where nothing opens one.
*/
function applyBreakpointState(isNarrow: boolean): void {
// body.sidebar-open sets overflow:hidden outside the media query, so a
// drawer open across a resize would lock scrolling with no drawer left.
document.body.classList.remove('sidebar-open');
hamburger!.setAttribute('aria-expanded', 'false');
if (overlay) overlay.setAttribute('aria-hidden', 'true');

if (!isNarrow) {
sidebar!.removeAttribute('aria-hidden');
sidebar!.removeAttribute('inert');
return;
}
// Focus survives the crossing when it was already on a sidebar link, and
// hiding the subtree under it strands the caret where no screen reader
// can follow. Chromium does not blur it for us when inert lands, so this
// has to run first.
if (sidebar!.contains(document.activeElement)) hamburger!.focus();
sidebar!.setAttribute('aria-hidden', 'true');
sidebar!.setAttribute('inert', '');
}

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();
}
});

// Projected topbar controls: place them for the current width, then follow
// the breakpoint. Listeners are bound once here, never per relocation.
const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
syncHeaderPlacement(mediaQuery.matches);
applyBreakpointState(mediaQuery.matches);
mediaQuery.addEventListener('change', event => {
syncHeaderPlacement(event.matches);
applyBreakpointState(event.matches);
});

// Account actions inside the drawer navigate or end the session, so they
// close it. Delegated from the slot, which outlives every relocation; the
// filter chips are deliberately excluded so picking a filter keeps the
// drawer open for the next one.
const extras = document.getElementById('sidebar-extras');
extras?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target?.closest('#user-info a, #user-info button:not(#user-email-display)')) return;
if (document.body.classList.contains('sidebar-open')) closeDrawer();
});
// The profile button opens a modal. Close the drawer in the capture phase,
// before the button's own handler runs: closing afterwards would pull focus
// from the modal back to the hamburger. Closing first lets the handler
// record the hamburger, not the now-inert button, as the return-focus trigger.
extras?.addEventListener('click', (e: MouseEvent) => {
if (!(e.target as HTMLElement | null)?.closest('#user-email-display')) return;
if (document.body.classList.contains('sidebar-open')) closeDrawer();
}, true);

// 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
8 changes: 4 additions & 4 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ <h1>CUDly</h1>
</nav>
<!-- Drawer slot for the topbar controls. Below the drawer
breakpoint the topbar has no room for the filter chips
and the account actions, so app.ts:setupMobileNav()
relocates #topbar-filters and #user-info in here, and
back out again above it (issue #1779). Empty on
desktop. -->
and the account actions, so
nav/mobile-nav.ts:setupMobileNav() relocates
#topbar-filters and #user-info in here, and back out
again above it (issue #1779). Empty on desktop. -->
<div class="app-sidebar-extras" id="sidebar-extras"></div>
</aside>
<main class="app-main" id="main-content">
Expand Down
Loading
Loading