Repository navigation
fix(frontend): move the topbar controls into the drawer below 768px #1877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b9fa8dd
48f7e49
d1157b4
fb57932
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -653,21 +653,69 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
|
Comment on lines
+656
to
+693
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Split mobile navigation from
As per coding guidelines, “Follow Domain-Driven Design with bounded contexts, keep files under 500 lines, and use typed interfaces for public APIs.” Also applies to: 761-783 🤖 Prompt for AI AgentsSource: Coding guidelines
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining this one here and tracking it as #1878 instead. The violation is real and predates this PR: #1878 carries the proposal, the current line counts, and the note that the overage predates this PR. The sibling finding on the widening path was a genuine defect and is fixed in 48f7e49, with two Playwright cases that fail against the previous head.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🐇 ✏️ Learnings added
🧠 Learnings usedYou are interacting with an AI system. |
||
|
|
||
| /** | ||
| * 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" | ||
| * 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 <body>. | ||
| 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'); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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<HTMLElement>('.tab-btn').forEach(link => { | ||
| link.addEventListener('click', (e: MouseEvent) => { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.