diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte index a6d408439..af2266728 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -22,7 +22,8 @@ SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_OVERSTRETCH_PX, - SIDEBAR_SETTLE_MS + SIDEBAR_SETTLE_MS, + SIDEBAR_TOGGLE_MS } from '$lib/constants'; import { SidebarAction, TooltipSide } from '$lib/enums'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; @@ -41,12 +42,29 @@ toggleSidebar: () => toggleExpandedMode() }); - let hoveredTooltip = $state(null); + let innerWidth = $state(0); let logoHovered = $state(false); + /** Set while the collapsed sidebar is revealed by hovering its logo, until the pointer leaves it. */ + let isPeeking = $state(false); + /** Set after the toggle closes the sidebar under the pointer, until the pointer leaves the logo. */ + let isPeekSuppressed = $state(false); + /** Keeps the collapsed sidebar in its strip-width slot while it slides closed or before it slides open. */ + let isDocked = $state(false); + /** Drops the transitions for the frame the sidebar swaps between docked and floating, out of sight. */ + let isSnapping = $state(false); + let dockTimeout: ReturnType | undefined; - const isStripExpanded = $derived(uiStore.isSidebarExpanded || hoveredTooltip !== null); const isOnMobile = $derived(deviceStore.isMobile); const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean); + // the collapsed sidebar shows only its logo until it is peeked at, which fades the rest in + const isStripHidden = $derived(!uiStore.isSidebarExpanded && !isOnMobile && !isPeeking); + // once settled, the collapsed sidebar keeps its full width, floating over the page, + // so a peek only fades it in instead of resizing it + const isFloating = $derived(!uiStore.isSidebarExpanded && !isOnMobile && !isDocked); + // on desktop the logo doubles as the sidebar toggle, unless the sidebar is pinned open + const isLogoToggle = $derived( + innerWidth > 768 && (!uiStore.isSidebarExpanded || !alwaysShowOnDesktop) + ); $effect(() => { if (alwaysShowOnDesktop && !isOnMobile) { @@ -54,11 +72,63 @@ } }); - function toggleExpandedMode() { - uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded; + /** Swaps between docked and floating without a transition, on the next frames. */ + function snapDocked(docked: boolean, then?: () => void) { + isSnapping = true; + isDocked = docked; - if (!uiStore.isSidebarExpanded) { - hoveredTooltip = null; + requestAnimationFrame(() => + requestAnimationFrame(() => { + isSnapping = false; + then?.(); + }) + ); + } + + function toggleExpandedMode() { + clearTimeout(dockTimeout); + + if (isOnMobile) { + uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded; + + return; + } + + if (uiStore.isSidebarExpanded) { + // slide closed at strip width, kept hidden under the pointer, then float once out of sight + isPeeking = false; + isPeekSuppressed = true; + isDocked = true; + uiStore.isSidebarExpanded = false; + dockTimeout = setTimeout(() => snapDocked(false), SIDEBAR_TOGGLE_MS); + + return; + } + + if (isPeeking) { + // already shown at full width, so only the page slides aside + isDocked = false; + uiStore.isSidebarExpanded = true; + + return; + } + + // dock to strip width out of sight, then slide open from there + snapDocked(true, () => { + isDocked = false; + uiStore.isSidebarExpanded = true; + }); + } + + function peek() { + if (!isPeekSuppressed) { + isPeeking = true; + } + } + + function handleAsideFocusOut(event: FocusEvent) { + if (!asideEl?.contains(event.relatedTarget as Node | null)) { + isPeeking = false; } } @@ -301,7 +371,6 @@ chatStore.stopGenerationForChat(id); } - let innerWidth = $state(0); let pendingCollapse = $state | null>(null); function scheduleMobileCollapse() { @@ -479,10 +548,11 @@ {#if innerWidth > 768 || !page.url.hash.includes(ROUTES.SEARCH)} +