diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 2d912da5ba..64c6988495 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -49,18 +49,35 @@ import { import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { + holdSidebarPeek, + releaseSidebarPeek, +} from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Avatar, Box } from "@radix-ui/themes"; import { ChevronRightIcon } from "lucide-react"; -import { type ReactNode, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; // The two-line user/project card used at the bottom of the sidebar. export function ProjectSwitcher() { const [popoverOpen, setPopoverOpen] = useState(false); + // Hold the sidebar's hover-peek open while this dropdown is open: it lives in + // a portal anchored to the trigger, so if the peek collapsed underneath it + // (pointer leaving the panel, e.g. toward a submenu flyout) the menu would be + // left floating over the content, chasing its vanished anchor. + const handleOpenChange = (next: boolean): void => { + setPopoverOpen(next); + if (next) holdSidebarPeek(); + else releaseSidebarPeek(); + }; + // Release if we unmount while the menu is open (e.g. a route change) so the + // hold can't outlive it. + useEffect(() => () => releaseSidebarPeek(), []); + const currentOrgId = useAuthStateValue((state) => state.currentOrgId); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); @@ -168,7 +185,7 @@ export function ProjectSwitcher() { }; return ( - + useSidebarPeekStore.getState().peek; + +describe("sidebarPeekStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Reset shared module-level state (hold flag + hide timer + peek) so each + // case starts clean. + cancelSidebarPeek(); + }); + + afterEach(() => { + cancelSidebarPeek(); + vi.useRealTimers(); + }); + + it("endSidebarPeek hides the peek only once the delay elapses", () => { + beginSidebarPeek(); + expect(isPeeked()).toBe(true); + + endSidebarPeek(200); + expect(isPeeked()).toBe(true); + + vi.advanceTimersByTime(200); + expect(isPeeked()).toBe(false); + }); + + it("keeps the peek open while held, then closes once released", () => { + beginSidebarPeek(); + holdSidebarPeek(); + + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(true); + + releaseSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(false); + }); + + it("holdSidebarPeek cancels a hide that is already pending", () => { + beginSidebarPeek(); + endSidebarPeek(200); + holdSidebarPeek(); + + vi.advanceTimersByTime(200); + expect(isPeeked()).toBe(true); + }); + + it("cancelSidebarPeek closes immediately and clears the hold", () => { + beginSidebarPeek(); + holdSidebarPeek(); + + cancelSidebarPeek(); + expect(isPeeked()).toBe(false); + + // The hold was cleared, so normal begin/end behaviour resumes. + beginSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(false); + }); +}); diff --git a/packages/ui/src/features/sidebar/sidebarPeekStore.ts b/packages/ui/src/features/sidebar/sidebarPeekStore.ts index ef9ec7bbdf..7040b48b5d 100644 --- a/packages/ui/src/features/sidebar/sidebarPeekStore.ts +++ b/packages/ui/src/features/sidebar/sidebarPeekStore.ts @@ -3,7 +3,9 @@ import { create } from "zustand"; // Ephemeral hover-peek state for the collapsed sidebar: hovering the left // gutter or the title-bar toggle slides the sidebar out as an overlay, and // leaving hides it. Re-entering any trigger before the hide fires keeps the -// peek alive. Not persisted. +// peek alive. A "hold" keeps it open regardless of pointer position while a +// menu spawned from the sidebar (e.g. the ProjectSwitcher dropdown) is open, so +// moving the pointer toward the menu can't slide the anchor away. Not persisted. interface SidebarPeekStore { peek: boolean; setPeek: (peek: boolean) => void; @@ -18,6 +20,12 @@ export const useSidebarPeekStore = create()((set) => ({ // panel itself) so re-entering any of them keeps the peek alive. let hideTimer: ReturnType | null = null; +// While a sidebar-spawned menu is open the peek is "held": endSidebarPeek is a +// no-op so a pointer that leaves the panel (e.g. toward a submenu flyout) can't +// collapse it and strand the open menu's portal anchor. Module-level to match +// hideTimer. +let held = false; + const clearHideTimer = (): void => { if (hideTimer) { clearTimeout(hideTimer); @@ -30,7 +38,21 @@ export function beginSidebarPeek(): void { useSidebarPeekStore.getState().setPeek(true); } +// Pin the peek open while a menu spawned from the sidebar is open, and release +// it when that menu closes. Paired open/close calls keep this balanced; +// releasing hands control back to the hover logic, which collapses the peek on +// the next pointer move outside the panel. +export function holdSidebarPeek(): void { + held = true; + clearHideTimer(); +} + +export function releaseSidebarPeek(): void { + held = false; +} + export function endSidebarPeek(delayMs = 0): void { + if (held) return; clearHideTimer(); hideTimer = setTimeout(() => { hideTimer = null; @@ -39,6 +61,7 @@ export function endSidebarPeek(delayMs = 0): void { } export function cancelSidebarPeek(): void { + held = false; clearHideTimer(); useSidebarPeekStore.getState().setPeek(false); }