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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-13 13:36:41 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e443eb3707
commit ce45ab5073
3 changed files with 107 additions and 20 deletions
@@ -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}
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
is on the handle, on the card, or in a menu either opened, and sliding back out when it
leaves. Where no pointer hovers, a tap opens it and it stays until dismissed. Attaching
leaves. A click or tap pins it instead, until another one here, a press on the page or Esc
puts it back. Attaching
it for good belongs to the toggle in the sidebar's own footer, where detaching it
happened. Docked, the sidebar speaks for itself and nothing leads the bar. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -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()}
/>
</div>
{/if}
@@ -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() {
@@ -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<HTMLElement>('[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 @@
})
</script>
<svelte:window bind:innerWidth onkeydown={onSearchShortcutBeforeLoad} />
<svelte:window
bind:innerWidth
onkeydown={onWindowKeydown}
onpointerdowncapture={closeCardOnPagePress}
onblur={closeCardOnFrameFocus}
/>
<!-- Home + Runs lifted to the top of the workspace nav, sitting with Favorites
and Search as the primary quick-access cluster. Excluded from SidebarContent
@@ -1365,20 +1420,18 @@
inert={!menuOpen}
role="dialog"
>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<!-- The card floats over the page, inset and rounded. Over an app it is not: the app
owns the window, and the sidebar revealed beside it reads as the panel the header
sits on — flush to the left and bottom edges, square, with one border down its
right side. -->
<!-- This outside-click catcher starts where the card does rather than at the top of
the viewport: it would otherwise cover the header's toggle, whose hover opened the
card, and swallow the click meant to put the sidebar back. -->
<!-- The frame spans the page only to position the card; it lets every press through
(closeCardOnPagePress dismisses the card). -->
<div
class={classNames('fixed left-0 right-0 bottom-0 flex z-40', 'pl-1 pb-2')}
class={classNames(
'fixed left-0 right-0 bottom-0 flex z-40 pointer-events-none',
'pl-1 pb-2'
)}
style:top="{navHandleSlot.cardTop}px"
onclick={(e) => {
if (e.target === e.currentTarget) menuOpen = false
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -1390,7 +1443,7 @@
schedulePeekClose()
}}
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden rounded-lg border',
'pointer-events-auto relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden rounded-lg border',
menuOpen ? 'translate-x-0' : '-translate-x-[calc(100%+0.5rem)]'
)}
>
@@ -1416,7 +1469,7 @@
</div>
{/if}
{/if}
<div class="flex flex-col h-full w-full relative">
<div bind:this={pageColumn} class="flex flex-col h-full w-full relative">
<!-- Not gated on `$enterpriseLicense`: it paints the cached accent before the license
resolves, and checks the license itself. -->
<InstanceUiSync />