From dd32b27d9ab07aa41ca4a999ea39a09161ca575d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:32:55 +0000 Subject: [PATCH 1/3] refactor(frontend): add a nav/mobile-nav module for the drawer and header projection app.ts is more than twice the 500-line ceiling in CLAUDE.md, and the mobile drawer plus the topbar-to-drawer projection is its largest self-contained chunk. This commit copies that code verbatim into its own bounded-context module, frontend/src/nav/mobile-nav.ts; the next commit switches app.ts and the tests over and deletes the original, so the move stays reviewable as two small diffs. The only edit is exporting MOBILE_NAV_QUERY so the breakpoint is part of the module's public surface. Refs #215 Co-Authored-By: Claude Opus 5.5 --- frontend/src/nav/mobile-nav.ts | 214 +++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 frontend/src/nav/mobile-nav.ts diff --git a/frontend/src/nav/mobile-nav.ts b/frontend/src/nav/mobile-nav.ts new file mode 100644 index 000000000..ec59ba452 --- /dev/null +++ b/frontend/src/nav/mobile-nav.ts @@ -0,0 +1,214 @@ +/** + * Mobile navigation bounded context: the hamburger drawer and the projection + * of topbar regions into it below the drawer breakpoint (issue #1779). + * + * Public API: + * - setupMobileNav() wires the drawer once at startup. + * - syncHeaderPlacement() moves the projected regions for a given width. + * - MOBILE_NAV_QUERY is the breakpoint both of the above follow. + * + * Extracted from app.ts (issue #215); behavior is unchanged. + */ + +/** + * 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. + */ +export 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('.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 . + 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( + '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('.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(); + } + }); + }); +} From 9c33555e6c62f7e666d70f22b4ca5538a216be97 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:32:55 +0000 Subject: [PATCH 2/3] refactor(frontend): wire app.ts to nav/mobile-nav and drop the inline copy Removes setupMobileNav, syncHeaderPlacement and their constants from app.ts (1052 -> 849 lines) and imports setupMobileNav from the new module instead. The jest suite now imports from the module directly, and the comments in index.html and responsive.css that pointed at app.ts name the new home. No behavior change: the code is the same, it is only called from a new file. Refs #215 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/mobile-nav.test.ts | 2 +- frontend/src/app.ts | 204 +--------------------- frontend/src/index.html | 8 +- frontend/src/styles/responsive.css | 6 +- 4 files changed, 9 insertions(+), 211 deletions(-) diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index efb795077..b0b74ab28 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -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 = ` diff --git a/frontend/src/app.ts b/frontend/src/app.ts index c73b9658e..aa28e9b2e 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -22,6 +22,7 @@ import { buildCommitmentSummary, commitmentTotals, executeButtonLabel, executeCo import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { setupMobileNav } from './nav/mobile-nav'; /** * describeDuplicateSubmit words the outcome of a submit the backend collapsed @@ -779,209 +780,6 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { 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('.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 . - 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( - '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('.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 */ diff --git a/frontend/src/index.html b/frontend/src/index.html index ebbbdd24c..899ee0619 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -89,10 +89,10 @@

CUDly

+ 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. -->
diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 0fbe0ee62..f0c8f43eb 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -73,7 +73,7 @@ a:active, * - .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(). + * - aria-expanded / aria-hidden toggled from nav/mobile-nav.ts:setupMobileNav(). * ------------------------------------------------------------------------- */ /* Hamburger button - mobile only */ @@ -170,8 +170,8 @@ body.sidebar-open { * `header { flex-direction: column }` rule, so every row past the first * overflowed past the gradient and painted its white text straight onto * the page content below. Both regions move into the drawer instead - * (app.ts:syncHeaderPlacement); laying them out away here covers the - * moment before that script has run. */ + * (nav/mobile-nav.ts:syncHeaderPlacement); laying them out away here + * covers the moment before that script has run. */ .app-topbar { justify-content: flex-start; gap: var(--cudly-sp-2); From d6e7fdedbd98e0b7ba2a4b55ae489bbff6bcc940 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:32:55 +0000 Subject: [PATCH 3/3] refactor(frontend): return a typed handle from setupMobileNav Issue #215 asks for the extracted module to expose a typed API rather than loose functions. setupMobileNav now returns a MobileNavHandle with open(), close() and isOpen(), backed by the same openDrawer/closeDrawer transitions the hamburger, overlay and Escape key use, or null when the page has no drawer to wire. app.ts ignores the return value, so startup behavior is unchanged. Adds jest coverage for the null case and for the handle driving the same aria-expanded, inert and focus changes as the hamburger. Refs #215 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/mobile-nav.test.ts | 23 +++++++++++++++++++ frontend/src/nav/mobile-nav.ts | 27 ++++++++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index b0b74ab28..2fe70ee9c 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -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()); + }); }); /** diff --git a/frontend/src/nav/mobile-nav.ts b/frontend/src/nav/mobile-nav.ts index ec59ba452..4674aae3c 100644 --- a/frontend/src/nav/mobile-nav.ts +++ b/frontend/src/nav/mobile-nav.ts @@ -3,7 +3,8 @@ * of topbar regions into it below the drawer breakpoint (issue #1779). * * Public API: - * - setupMobileNav() wires the drawer once at startup. + * - setupMobileNav() wires the drawer once at startup and returns a + * MobileNavHandle (or null when the page has no hamburger/sidebar). * - syncHeaderPlacement() moves the projected regions for a given width. * - MOBILE_NAV_QUERY is the breakpoint both of the above follow. * @@ -16,6 +17,16 @@ */ export const MOBILE_NAV_QUERY = '(max-width: 768px)'; +/** + * Handle returned by setupMobileNav. open and close are the same transitions + * the hamburger, overlay and Escape key drive, focus moves included. + */ +export interface MobileNavHandle { + open(): void; + close(): void; + isOpen(): boolean; +} + /** * 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. @@ -74,12 +85,16 @@ export function syncHeaderPlacement(isNarrow: boolean): void { * an open nor a close: see applyBreakpointState. * * Sidebar links also close the drawer (they navigate within the SPA). + * + * Returns a handle on the wired drawer, or null when the page has no + * hamburger button or sidebar to wire. Call it once: every call binds its + * own listeners. */ -export function setupMobileNav(): void { +export function setupMobileNav(): MobileNavHandle | null { 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; + if (!hamburger || !sidebar) return null; function openDrawer(): void { document.body.classList.add('sidebar-open'); @@ -211,4 +226,10 @@ export function setupMobileNav(): void { } }); }); + + return { + open: openDrawer, + close: closeDrawer, + isOpen: () => document.body.classList.contains('sidebar-open'), + }; }