Skip to content

Commit 254afc4

Browse files
fix(sidebar): keep peeked sidebar open while the task Filter menu is open (#3666)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent cf8d999 commit 254afc4

6 files changed

Lines changed: 138 additions & 23 deletions

File tree

packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,34 +49,24 @@ import {
4949
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
5050
import { useProjects } from "@posthog/ui/features/projects/useProjects";
5151
import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings";
52-
import {
53-
holdSidebarPeek,
54-
releaseSidebarPeek,
55-
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
52+
import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek";
5653
import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore";
5754
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
5855
import { isMac } from "@posthog/ui/utils/platform";
5956
import { getPostHogUrl } from "@posthog/ui/utils/urls";
6057
import { Avatar, Box } from "@radix-ui/themes";
6158
import { ChevronRightIcon } from "lucide-react";
62-
import { type ReactNode, useEffect, useMemo, useState } from "react";
59+
import { type ReactNode, useMemo, useState } from "react";
6360

6461
// The two-line user/project card used at the bottom of the sidebar.
6562
export function ProjectSwitcher() {
6663
const [popoverOpen, setPopoverOpen] = useState(false);
6764

68-
// Hold the sidebar's hover-peek open while this dropdown is open: it lives in
69-
// a portal anchored to the trigger, so if the peek collapsed underneath it
70-
// (pointer leaving the panel, e.g. toward a submenu flyout) the menu would be
71-
// left floating over the content, chasing its vanished anchor.
65+
const holdPeek = useHoldSidebarPeek();
7266
const handleOpenChange = (next: boolean): void => {
7367
setPopoverOpen(next);
74-
if (next) holdSidebarPeek();
75-
else releaseSidebarPeek();
68+
holdPeek(next);
7669
};
77-
// Release if we unmount while the menu is open (e.g. a route change) so the
78-
// hold can't outlive it.
79-
useEffect(() => () => releaseSidebarPeek(), []);
8070

8171
const currentOrgId = useAuthStateValue((state) => state.currentOrgId);
8272
const client = useOptionalAuthenticatedClient();

packages/ui/src/features/sidebar/components/TasksHeader.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { WorkspaceMode } from "@posthog/shared";
2727
import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery";
2828
import { useFolders } from "@posthog/ui/features/folders/useFolders";
2929
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
30+
import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek";
3031
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
3132
import { toast } from "@posthog/ui/primitives/toast";
3233
import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore";
@@ -105,8 +106,10 @@ function TaskFilterMenu() {
105106
const { data: currentUser } = useMeQuery();
106107
const isStaff = currentUser?.is_staff === true;
107108

109+
const handleOpenChange = useHoldSidebarPeek();
110+
108111
return (
109-
<DropdownMenu>
112+
<DropdownMenu onOpenChange={handleOpenChange}>
110113
<DropdownMenuTrigger
111114
render={
112115
<Button type="button" aria-label="Filter tasks" size="icon-sm">

packages/ui/src/features/sidebar/sidebarPeekStore.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const isPeeked = (): boolean => useSidebarPeekStore.getState().peek;
1313
describe("sidebarPeekStore", () => {
1414
beforeEach(() => {
1515
vi.useFakeTimers();
16-
// Reset shared module-level state (hold flag + hide timer + peek) so each
16+
// Reset shared module-level state (hold count + hide timer + peek) so each
1717
// case starts clean.
1818
cancelSidebarPeek();
1919
});
@@ -48,6 +48,32 @@ describe("sidebarPeekStore", () => {
4848
expect(isPeeked()).toBe(false);
4949
});
5050

51+
it("keeps the peek held until every holder has released", () => {
52+
beginSidebarPeek();
53+
holdSidebarPeek();
54+
holdSidebarPeek();
55+
56+
releaseSidebarPeek();
57+
endSidebarPeek(0);
58+
vi.runAllTimers();
59+
expect(isPeeked()).toBe(true);
60+
61+
releaseSidebarPeek();
62+
endSidebarPeek(0);
63+
vi.runAllTimers();
64+
expect(isPeeked()).toBe(false);
65+
});
66+
67+
it("releaseSidebarPeek without a hold does not break a later hold", () => {
68+
releaseSidebarPeek();
69+
70+
beginSidebarPeek();
71+
holdSidebarPeek();
72+
endSidebarPeek(0);
73+
vi.runAllTimers();
74+
expect(isPeeked()).toBe(true);
75+
});
76+
5177
it("holdSidebarPeek cancels a hide that is already pending", () => {
5278
beginSidebarPeek();
5379
endSidebarPeek(200);

packages/ui/src/features/sidebar/sidebarPeekStore.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ let hideTimer: ReturnType<typeof setTimeout> | null = null;
2222

2323
// While a sidebar-spawned menu is open the peek is "held": endSidebarPeek is a
2424
// no-op so a pointer that leaves the panel (e.g. toward a submenu flyout) can't
25-
// collapse it and strand the open menu's portal anchor. Module-level to match
26-
// hideTimer.
27-
let held = false;
25+
// collapse it and strand the open menu's portal anchor. Counted, not boolean,
26+
// so one menu's release can't drop a hold another menu still needs.
27+
let holdCount = 0;
2828

2929
const clearHideTimer = (): void => {
3030
if (hideTimer) {
@@ -43,16 +43,16 @@ export function beginSidebarPeek(): void {
4343
// releasing hands control back to the hover logic, which collapses the peek on
4444
// the next pointer move outside the panel.
4545
export function holdSidebarPeek(): void {
46-
held = true;
46+
holdCount += 1;
4747
clearHideTimer();
4848
}
4949

5050
export function releaseSidebarPeek(): void {
51-
held = false;
51+
holdCount = Math.max(0, holdCount - 1);
5252
}
5353

5454
export function endSidebarPeek(delayMs = 0): void {
55-
if (held) return;
55+
if (holdCount > 0) return;
5656
clearHideTimer();
5757
hideTimer = setTimeout(() => {
5858
hideTimer = null;
@@ -61,7 +61,7 @@ export function endSidebarPeek(delayMs = 0): void {
6161
}
6262

6363
export function cancelSidebarPeek(): void {
64-
held = false;
64+
holdCount = 0;
6565
clearHideTimer();
6666
useSidebarPeekStore.getState().setPeek(false);
6767
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
beginSidebarPeek,
5+
cancelSidebarPeek,
6+
endSidebarPeek,
7+
holdSidebarPeek,
8+
useSidebarPeekStore,
9+
} from "./sidebarPeekStore";
10+
import { useHoldSidebarPeek } from "./useHoldSidebarPeek";
11+
12+
const isPeeked = (): boolean => useSidebarPeekStore.getState().peek;
13+
14+
const expectPeekAfterEnd = (expected: boolean): void => {
15+
endSidebarPeek(0);
16+
act(() => {
17+
vi.runAllTimers();
18+
});
19+
expect(isPeeked()).toBe(expected);
20+
};
21+
22+
describe("useHoldSidebarPeek", () => {
23+
beforeEach(() => {
24+
vi.useFakeTimers();
25+
cancelSidebarPeek();
26+
});
27+
28+
afterEach(() => {
29+
cancelSidebarPeek();
30+
vi.useRealTimers();
31+
});
32+
33+
it("holds while open and releases on close", () => {
34+
beginSidebarPeek();
35+
const { result } = renderHook(() => useHoldSidebarPeek());
36+
37+
act(() => result.current(true));
38+
expectPeekAfterEnd(true);
39+
40+
act(() => result.current(false));
41+
expectPeekAfterEnd(false);
42+
});
43+
44+
it("releases on unmount while open", () => {
45+
beginSidebarPeek();
46+
const { result, unmount } = renderHook(() => useHoldSidebarPeek());
47+
48+
act(() => result.current(true));
49+
unmount();
50+
expectPeekAfterEnd(false);
51+
});
52+
53+
it("unmounting without opening leaves another holder's hold intact", () => {
54+
beginSidebarPeek();
55+
holdSidebarPeek();
56+
const { unmount } = renderHook(() => useHoldSidebarPeek());
57+
58+
unmount();
59+
expectPeekAfterEnd(true);
60+
});
61+
62+
it("repeated open events acquire a single hold", () => {
63+
beginSidebarPeek();
64+
const { result } = renderHook(() => useHoldSidebarPeek());
65+
66+
act(() => result.current(true));
67+
act(() => result.current(true));
68+
act(() => result.current(false));
69+
expectPeekAfterEnd(false);
70+
});
71+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {
2+
holdSidebarPeek,
3+
releaseSidebarPeek,
4+
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
5+
import { useCallback, useEffect, useRef } from "react";
6+
7+
// Sidebar menus render in portals; holding the peek while one is open stops the
8+
// sidebar collapsing underneath it and stranding the menu's anchor.
9+
export function useHoldSidebarPeek(): (open: boolean) => void {
10+
const holdingRef = useRef(false);
11+
12+
useEffect(
13+
() => () => {
14+
if (holdingRef.current) releaseSidebarPeek();
15+
},
16+
[],
17+
);
18+
19+
return useCallback((open: boolean) => {
20+
if (open === holdingRef.current) return;
21+
holdingRef.current = open;
22+
if (open) holdSidebarPeek();
23+
else releaseSidebarPeek();
24+
}, []);
25+
}

0 commit comments

Comments
 (0)