Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/features/sidebar/components/TasksHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,8 +106,10 @@ function TaskFilterMenu() {
const { data: currentUser } = useMeQuery();
const isStaff = currentUser?.is_staff === true;

const handleOpenChange = useHoldSidebarPeek();

return (
<DropdownMenu>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
render={
<Button type="button" aria-label="Filter tasks" size="icon-sm">
Expand Down
28 changes: 27 additions & 1 deletion packages/ui/src/features/sidebar/sidebarPeekStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 7 additions & 7 deletions packages/ui/src/features/sidebar/sidebarPeekStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ let hideTimer: ReturnType<typeof setTimeout> | 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) {
Expand All @@ -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;
Expand All @@ -61,7 +61,7 @@ export function endSidebarPeek(delayMs = 0): void {
}

export function cancelSidebarPeek(): void {
held = false;
holdCount = 0;
clearHideTimer();
useSidebarPeekStore.getState().setPeek(false);
}
71 changes: 71 additions & 0 deletions packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions packages/ui/src/features/sidebar/useHoldSidebarPeek.ts
Original file line number Diff line number Diff line change
@@ -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();
}, []);
}
Loading