diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index 694ec5c31..481894e78 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -5,23 +5,39 @@ * aria-expanded toggle, focus management, body scroll-lock class. */ -import { setupMobileNav } from '../app'; +import { setupMobileNav, syncHeaderPlacement } from '../app'; function buildDOM(): void { document.body.innerHTML = ` - +
+ +

CUDly

+
+
+ + + API Docs + + +
+
`; } +function hamburgerBtn(): HTMLButtonElement { + return document.getElementById('hamburger-btn') as HTMLButtonElement; +} + describe('setupMobileNav', () => { beforeEach(() => { buildDOM(); @@ -151,3 +167,80 @@ describe('setupMobileNav', () => { expect(() => setupMobileNav()).not.toThrow(); }); }); + +/** + * Placement of the topbar controls (issue #1779). + * + * These assert DOM parentage only. Whether the relocated controls are + * visible, on-screen and large enough to tap is a layout question jsdom + * cannot answer -- tests-e2e/mobile-header-drawer.spec.ts measures that in + * a real browser at a 390px viewport. + */ +describe('syncHeaderPlacement', () => { + beforeEach(buildDOM); + afterEach(() => { + document.body.innerHTML = ''; + }); + + const projected = (): HTMLElement[] => [ + document.getElementById('topbar-filters')!, + document.getElementById('user-info')!, + ]; + + function parentIds(): (string | undefined)[] { + return projected().map(el => el.parentElement?.id); + } + + test('narrow moves the filters and the account actions into the drawer slot', () => { + syncHeaderPlacement(true); + expect(parentIds()).toEqual(['sidebar-extras', 'sidebar-extras']); + }); + + test('widening restores both to the topbar, in header order', () => { + syncHeaderPlacement(true); + syncHeaderPlacement(false); + + const topbar = document.querySelector('.app-topbar')!; + expect(projected().every(el => el.parentElement === topbar)).toBe(true); + const ids = Array.from(topbar.children).map(el => el.id).filter(Boolean); + expect(ids.indexOf('topbar-filters')).toBeLessThan(ids.indexOf('user-info')); + }); + + test('repeated narrow calls do not re-append the same nodes', () => { + syncHeaderPlacement(true); + const marker = document.createElement('span'); + projected()[0]!.appendChild(marker); + // A redundant appendChild would tear the subtree out and back in, + // dropping focus and closing any open chip popover. + syncHeaderPlacement(true); + expect(marker.isConnected).toBe(true); + expect(document.getElementById('sidebar-extras')!.children).toHaveLength(2); + }); + + test('no-op when the drawer slot is absent', () => { + const topbar = document.querySelector('.app-topbar')!; + document.getElementById('sidebar-extras')!.remove(); + expect(() => syncHeaderPlacement(true)).not.toThrow(); + expect(projected().every(el => el.parentElement === topbar)).toBe(true); + }); + + test('clicking an account action in the drawer closes it', () => { + setupMobileNav(); + syncHeaderPlacement(true); + hamburgerBtn().click(); + document.getElementById('logout-btn')!.dispatchEvent( + new MouseEvent('click', { button: 0, bubbles: true }), + ); + expect(document.body.classList.contains('sidebar-open')).toBe(false); + }); + + test('using a filter chip in the drawer leaves it open', () => { + setupMobileNav(); + syncHeaderPlacement(true); + hamburgerBtn().click(); + const chip = document.createElement('button'); + document.getElementById('topbar-filters')!.appendChild(chip); + chip.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true })); + expect(document.body.classList.contains('sidebar-open')).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/responsive.test.ts b/frontend/src/__tests__/responsive.test.ts index 9af1e3eff..ea88cd53d 100644 --- a/frontend/src/__tests__/responsive.test.ts +++ b/frontend/src/__tests__/responsive.test.ts @@ -41,10 +41,53 @@ describe('responsive.css nav wrap rules', () => { expect(tabsRule?.[0] ?? '').not.toMatch(/overflow-x/); }); - it('wraps #user-info inside the ≤768px block so header children do not overflow', () => { + /** + * On the lazy `([\s\S]*?)\n}` capture used here and below: it does reach + * the end of the 768px block, despite looking like it would stop at the + * first nested rule. Every rule inside the block is indented, so the + * block's own closing brace is the only `}` at column zero and `\n}` can + * only anchor there. Measured against a brace-matched extraction: the true + * body is 5260 characters, the capture is 5259, the whole difference being + * the trailing newline the pattern consumes. + * + * This holds only while the stylesheet stays formatted that way. A nested + * rule closed at column zero would truncate the capture and these + * assertions would start passing or failing for the wrong reason. + * + * Issue #10 wrapped #user-info at ≤768px to stop it overflowing the + * header. That never worked: the topbar is a fixed --cudly-topbar-h box, + * so the extra rows painted over the page content instead (issue #1779). + * The regions are relocated into the drawer now, and the topbar copies + * are laid out away rather than left to paint behind the content. + * + * Source-text assertions only -- whether the relocated controls are + * actually visible and tappable is measured in a real browser by + * tests-e2e/mobile-header-drawer.spec.ts. + */ + it('takes #user-info and #topbar-filters out of the topbar at ≤768px', () => { const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/); expect(match).not.toBeNull(); const body = match?.[1] ?? ''; - expect(body).toMatch(/#user-info\s*{[\s\S]*?flex-wrap:\s*wrap/); + expect(body).toMatch( + /\.app-topbar\s*>\s*#topbar-filters,\s*\.app-topbar\s*>\s*#user-info\s*{[\s\S]*?display:\s*none/, + ); + }); + + it('styles both regions for the drawer slot at ≤768px', () => { + const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/); + expect(match).not.toBeNull(); + const body = match?.[1] ?? ''; + expect(body).toMatch(/\.app-sidebar-extras\s*{[\s\S]*?display:\s*flex/); + expect(body).toMatch(/\.app-sidebar-extras #topbar-filters/); + expect(body).toMatch(/\.app-sidebar-extras #user-info/); + }); + + it('does not stack the topbar into a column at ≤768px', () => { + const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/); + expect(match).not.toBeNull(); + const body = match?.[1] ?? ''; + // The `header { flex-direction: column }` rule was the overflow source. + expect(body).not.toMatch(/(^|\n)\s*header\s*{/); + expect(body).toMatch(/\.app-topbar\s*{[\s\S]*?justify-content:\s*flex-start/); }); }); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 329de7248..a916916ca 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -82,6 +82,25 @@ if (typeof globalThis.structuredClone === 'undefined') { }) as typeof structuredClone; } +// jsdom evaluates no CSS media queries and so ships no window.matchMedia, +// which setupMobileNav() calls to decide where the topbar controls live. +// Stub it as never-matching: jsdom has no viewport width to honour, so the +// desktop branch is the only honest default. The narrow branch is asserted +// by calling syncHeaderPlacement directly, and measured for real in the +// Playwright suite, which has actual layout. +if (typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(() => false), + })) as unknown as typeof window.matchMedia; +} + // Mock alert and confirm global.alert = jest.fn(); global.confirm = jest.fn(() => true); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index bbeb3f183..73bd73f17 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -653,6 +653,45 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { } } +/** + * 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). * @@ -660,14 +699,23 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { * * Open: body.classList.add('sidebar-open') * hamburger aria-expanded="true" - * sidebar aria-hidden="false" + * 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" + * 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 { @@ -680,6 +728,9 @@ export function setupMobileNav(): 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'); // Focus the first focusable element in the sidebar @@ -693,10 +744,46 @@ export function setupMobileNav(): 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) { @@ -719,6 +806,27 @@ export function setupMobileNav(): void { } }); + // 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')) return; + if (document.body.classList.contains('sidebar-open')) closeDrawer(); + }); + // Clicking any sidebar link closes the drawer (SPA navigation) sidebar.querySelectorAll('.tab-btn').forEach(link => { link.addEventListener('click', (e: MouseEvent) => { diff --git a/frontend/src/index.html b/frontend/src/index.html index 1c659dacd..cb019881a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -87,6 +87,13 @@

CUDly

Admin + +