mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
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:
co-authored by
Claude Opus 5.5
parent
e443eb3707
commit
ce45ab5073
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user