diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 64c6988495..08f4520ec3 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -49,34 +49,24 @@ 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 { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; 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, useEffect, useMemo, useState } from "react"; +import { type ReactNode, 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 holdPeek = useHoldSidebarPeek(); const handleOpenChange = (next: boolean): void => { setPopoverOpen(next); - if (next) holdSidebarPeek(); - else releaseSidebarPeek(); + holdPeek(next); }; - // 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(); diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index ebe4066a59..edb49e4e8e 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -27,6 +27,7 @@ import type { WorkspaceMode } from "@posthog/shared"; import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { toast } from "@posthog/ui/primitives/toast"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; @@ -105,8 +106,10 @@ function TaskFilterMenu() { const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; + const handleOpenChange = useHoldSidebarPeek(); + return ( - + diff --git a/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts b/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts index 8c2188b149..640a3f73f0 100644 --- a/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts +++ b/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts @@ -13,7 +13,7 @@ const isPeeked = (): boolean => useSidebarPeekStore.getState().peek; describe("sidebarPeekStore", () => { beforeEach(() => { vi.useFakeTimers(); - // Reset shared module-level state (hold flag + hide timer + peek) so each + // Reset shared module-level state (hold count + hide timer + peek) so each // case starts clean. cancelSidebarPeek(); }); @@ -48,6 +48,32 @@ describe("sidebarPeekStore", () => { expect(isPeeked()).toBe(false); }); + it("keeps the peek held until every holder has released", () => { + beginSidebarPeek(); + holdSidebarPeek(); + holdSidebarPeek(); + + releaseSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(true); + + releaseSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(false); + }); + + it("releaseSidebarPeek without a hold does not break a later hold", () => { + releaseSidebarPeek(); + + beginSidebarPeek(); + holdSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(true); + }); + it("holdSidebarPeek cancels a hide that is already pending", () => { beginSidebarPeek(); endSidebarPeek(200); diff --git a/packages/ui/src/features/sidebar/sidebarPeekStore.ts b/packages/ui/src/features/sidebar/sidebarPeekStore.ts index 7040b48b5d..faec675002 100644 --- a/packages/ui/src/features/sidebar/sidebarPeekStore.ts +++ b/packages/ui/src/features/sidebar/sidebarPeekStore.ts @@ -22,9 +22,9 @@ 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; +// collapse it and strand the open menu's portal anchor. Counted, not boolean, +// so one menu's release can't drop a hold another menu still needs. +let holdCount = 0; const clearHideTimer = (): void => { if (hideTimer) { @@ -43,16 +43,16 @@ export function beginSidebarPeek(): void { // 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; + holdCount += 1; clearHideTimer(); } export function releaseSidebarPeek(): void { - held = false; + holdCount = Math.max(0, holdCount - 1); } export function endSidebarPeek(delayMs = 0): void { - if (held) return; + if (holdCount > 0) return; clearHideTimer(); hideTimer = setTimeout(() => { hideTimer = null; @@ -61,7 +61,7 @@ export function endSidebarPeek(delayMs = 0): void { } export function cancelSidebarPeek(): void { - held = false; + holdCount = 0; clearHideTimer(); useSidebarPeekStore.getState().setPeek(false); } diff --git a/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx b/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx new file mode 100644 index 0000000000..35ab7e3e66 --- /dev/null +++ b/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx @@ -0,0 +1,71 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginSidebarPeek, + cancelSidebarPeek, + endSidebarPeek, + holdSidebarPeek, + useSidebarPeekStore, +} from "./sidebarPeekStore"; +import { useHoldSidebarPeek } from "./useHoldSidebarPeek"; + +const isPeeked = (): boolean => useSidebarPeekStore.getState().peek; + +const expectPeekAfterEnd = (expected: boolean): void => { + endSidebarPeek(0); + act(() => { + vi.runAllTimers(); + }); + expect(isPeeked()).toBe(expected); +}; + +describe("useHoldSidebarPeek", () => { + beforeEach(() => { + vi.useFakeTimers(); + cancelSidebarPeek(); + }); + + afterEach(() => { + cancelSidebarPeek(); + vi.useRealTimers(); + }); + + it("holds while open and releases on close", () => { + beginSidebarPeek(); + const { result } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + expectPeekAfterEnd(true); + + act(() => result.current(false)); + expectPeekAfterEnd(false); + }); + + it("releases on unmount while open", () => { + beginSidebarPeek(); + const { result, unmount } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + unmount(); + expectPeekAfterEnd(false); + }); + + it("unmounting without opening leaves another holder's hold intact", () => { + beginSidebarPeek(); + holdSidebarPeek(); + const { unmount } = renderHook(() => useHoldSidebarPeek()); + + unmount(); + expectPeekAfterEnd(true); + }); + + it("repeated open events acquire a single hold", () => { + beginSidebarPeek(); + const { result } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + act(() => result.current(true)); + act(() => result.current(false)); + expectPeekAfterEnd(false); + }); +}); diff --git a/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts b/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts new file mode 100644 index 0000000000..3b9ff67976 --- /dev/null +++ b/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts @@ -0,0 +1,25 @@ +import { + holdSidebarPeek, + releaseSidebarPeek, +} from "@posthog/ui/features/sidebar/sidebarPeekStore"; +import { useCallback, useEffect, useRef } from "react"; + +// Sidebar menus render in portals; holding the peek while one is open stops the +// sidebar collapsing underneath it and stranding the menu's anchor. +export function useHoldSidebarPeek(): (open: boolean) => void { + const holdingRef = useRef(false); + + useEffect( + () => () => { + if (holdingRef.current) releaseSidebarPeek(); + }, + [], + ); + + return useCallback((open: boolean) => { + if (open === holdingRef.current) return; + holdingRef.current = open; + if (open) holdSidebarPeek(); + else releaseSidebarPeek(); + }, []); +}