From ce45ab507370662266728bf57240cc873ef28eb8 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 13 Oct 2026 15:36:41 +0200 Subject: [PATCH] fix: pin the floating sidebar and let page clicks through it (#11711) * fix: let clicks through the floating sidebar's outside layer The undocked sidebar's outside-click layer swallowed the click that dismissed it, so a page button under it needed two clicks. Dismiss on a page pointerdown instead and let the press reach its target; close on Esc; make the header handle toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: leave the floating sidebar open on an Esc an overlay claims Esc now waits for a drawer or menu to claim the key, skips while an overlay is on the viewport stack, ignores hidden menus, and returns focus to the handle. The hover-then-click grace on the handle is now time-bound, so a stale hover never swallows a later toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pin the floating sidebar from its handle A click on the handle pins the card, so it no longer slides away when the pointer leaves; the handle shows as selected while pinned. Another click on the handle, a press on the page, Esc or a navigation closes it. Hover still previews it unpinned. This replaces the one-second window that ignored a click right after a hover. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 7 +- .../sidebar/navHandlePlacement.svelte.ts | 35 +++++++- .../src/routes/(root)/(logged)/+layout.svelte | 85 +++++++++++++++---- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 0cc47e58ca..f30a77cba5 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -106,7 +106,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba {#if (navDetached.val || panelled) && !navHidden} @@ -123,7 +124,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba iconOnly startIcon={{ icon: PanelLeft }} aria-label="Show sidebar" - onclick={() => navHandleSlot.open()} + selected={navHandleSlot.pinned} + aria-pressed={navHandleSlot.pinned} + onclick={() => navHandleSlot.toggle()} /> {/if} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts index 5fb7b43860..8648961ef6 100644 --- a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -6,6 +6,13 @@ let opener: (() => void) | undefined // Closing it once the pointer is off both the handle and the card, which the layout owns because // the card is its markup. let hoverClose: { schedule: () => void; cancel: () => void } | undefined +type CardState = { + isOpen: () => boolean + isPinned: () => boolean + pin: () => void + close: () => void +} +let card: CardState | undefined // Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than // the card starting at whatever height the control that opened it happens to have. Measured at open @@ -21,10 +28,34 @@ export const navHandleSlot = { get cardTop(): number { return cardTop }, - /** The root layout owns the card, so it registers how to open and hover-close it. */ - setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) { + /** The card is open and pinned, so the handle shows as selected. Reactive. */ + get pinned(): boolean { + return !!card?.isOpen() && card.isPinned() + }, + /** The root layout owns the card, so it registers how to open, pin, close and hover-close it. */ + setOpener( + open: () => void, + close?: { schedule: () => void; cancel: () => void }, + state?: CardState + ) { opener = open hoverClose = close + card = state + }, + /** + * A click on the handle pins the card, opening it first if it is closed: a pinned card stays + * when the pointer leaves, until a press on the page, Esc, or the next click here closes it. A + * card the pointer opened is not pinned yet, so the click that follows the hover pins it — and + * a touch tap, which sends that mouseenter just before its click, pins it too. + */ + toggle(anchor?: Element | null) { + if (card?.isOpen() && card.isPinned()) { + card.close() + return + } + if (!card?.isOpen()) this.open(anchor) + hoverClose?.cancel() + card?.pin() }, /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ scheduleClose() { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 868d78c73b..2dcdeab5dd 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -86,6 +86,7 @@ import { twMerge } from 'tailwind-merge' import { navDetached } from '$lib/components/sidebar/navDetached.svelte' import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte' + import { overlayStack } from '$lib/components/common/overlayHost.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' @@ -514,8 +515,11 @@ function pointerInMenu(): boolean { return !!document.querySelector('[role="menu"]:hover') } + // A click on the handle pins the card: it then outlives the pointer leaving. Only meaningful + // while open — every open from closed starts unpinned. + let menuPinned = $state(false) const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => { - if (!peeksOnHover) return + if (!peeksOnHover || menuPinned) return if (pointerInMenu()) { schedulePeekClose() return @@ -528,10 +532,56 @@ const floatBand = $derived(pageHeader.content?.barRightInset != null) // The handle and the edge band both open the card this layout owns. - navHandleSlot.setOpener(() => (menuOpen = true), { - schedule: schedulePeekClose, - cancel: cancelPeekClose - }) + navHandleSlot.setOpener( + () => { + if (!menuOpen) menuPinned = false + menuOpen = true + }, + { schedule: schedulePeekClose, cancel: cancelPeekClose }, + { + isOpen: () => menuOpen, + isPinned: () => menuPinned, + pin: () => (menuPinned = true), + close: () => (menuOpen = false) + } + ) + + // The open card is dismissed by any press on the page, and that press still reaches its target: + // a layer that caught it would cost every dismissal a second click. Only presses inside the page + // column count — the card itself and the menus it portals to `body` sit outside it — and the + // handle toggles the card itself. Capture phase, so a target that stops propagation still closes it. + let pageColumn: HTMLElement | undefined = $state() + function closeCardOnPagePress(e: PointerEvent) { + if (!menuOpen || !useDrawer) return + const target = e.target + if (!(target instanceof Element) || !pageColumn?.contains(target)) return + if (target.closest('[data-nav-handle]')) return + menuOpen = false + } + // A press inside an iframe (a session's preview) never reaches this window; focus moving into one does. + function closeCardOnFrameFocus() { + if (!menuOpen || !useDrawer) return + setTimeout(() => { + if (document.activeElement instanceof HTMLIFrameElement) menuOpen = false + }) + } + + const viewportOverlays = overlayStack() + function onWindowKeydown(e: KeyboardEvent) { + onSearchShortcutBeforeLoad(e) + if (e.key !== 'Escape' || !menuOpen || !useDrawer) return + // Read now: the overlay this Esc closes is off the stack by the time the timeout runs. + if (viewportOverlays.val.length > 0) return + // This listener is attached before any drawer's or menu's, which mount later: wait for + // them, so an Esc that closes one of those leaves the card open. + setTimeout(() => { + if (e.defaultPrevented || document.querySelector('[role="menu"]:not([hidden])')) return + const focusInCard = !!document.activeElement?.closest('[data-nav-card]') + menuOpen = false + // The closed card is inert and would drop focus to `body`. + if (focusInCard) document.querySelector('[data-nav-handle] button')?.focus() + }) + } // Set once the user docks or detaches, so the rail that mounts from then on skips its // app-entry animation (`wm-sidebar-in`) and only plays `railMorph`. @@ -1041,7 +1091,12 @@ }) - + - +
{ - if (e.target === e.currentTarget) menuOpen = false - }} >
@@ -1416,7 +1469,7 @@
{/if} {/if} -
+