From e3783de1d8b75fe4a8bf586db571f931ab6cd60d Mon Sep 17 00:00:00 2001 From: aleexzxzxzx Date: Wed, 15 Jul 2026 13:37:50 -0700 Subject: [PATCH] fix(sidebar): keep peeked sidebar open while the ProjectSwitcher menu is open With hover-peek active, opening the ProjectSwitcher dropdown and then moving the pointer off the panel collapsed the sidebar while the menu (rendered in a portal anchored to its trigger) stayed open, leaving it floating and chasing its vanished anchor. Add a "hold" to sidebarPeekStore: while a sidebar-spawned menu is open, endSidebarPeek is a no-op, so the peek stays until the menu closes. The ProjectSwitcher holds the peek on open and releases it on close (and on unmount). Hover behaviour is unchanged when no menu is open. Adds a unit test for the hold. Generated-By: PostHog Code Task-Id: 61d7100a-78b9-41ca-9226-9b1bee296f1d --- .../sidebar/components/ProjectSwitcher.tsx | 21 +++++- .../features/sidebar/sidebarPeekStore.test.ts | 73 +++++++++++++++++++ .../src/features/sidebar/sidebarPeekStore.ts | 25 ++++++- 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/sidebar/sidebarPeekStore.test.ts 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); }