Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit bca6aa1

Browse files
aleexzxzxzxtatoalo
andauthored
fix(sidebar): keep the peeked sidebar open while the ProjectSwitcher menu is open (#3484)
Co-authored-by: Alessandro Pogliaghi <alessandro@posthog.com>
1 parent 437c331 commit bca6aa1

3 files changed

Lines changed: 116 additions & 3 deletions

File tree

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,35 @@ 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";
5256
import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore";
5357
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
5458
import { isMac } from "@posthog/ui/utils/platform";
5559
import { getPostHogUrl } from "@posthog/ui/utils/urls";
5660
import { Avatar, Box } from "@radix-ui/themes";
5761
import { ChevronRightIcon } from "lucide-react";
58-
import { type ReactNode, useMemo, useState } from "react";
62+
import { type ReactNode, useEffect, useMemo, useState } from "react";
5963

6064
// The two-line user/project card used at the bottom of the sidebar.
6165
export function ProjectSwitcher() {
6266
const [popoverOpen, setPopoverOpen] = useState(false);
6367

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.
72+
const handleOpenChange = (next: boolean): void => {
73+
setPopoverOpen(next);
74+
if (next) holdSidebarPeek();
75+
else releaseSidebarPeek();
76+
};
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(), []);
80+
6481
const currentOrgId = useAuthStateValue((state) => state.currentOrgId);
6582
const client = useOptionalAuthenticatedClient();
6683
const { data: currentUser } = useCurrentUser({ client });
@@ -168,7 +185,7 @@ export function ProjectSwitcher() {
168185
};
169186

170187
return (
171-
<DropdownMenu open={popoverOpen} onOpenChange={setPopoverOpen}>
188+
<DropdownMenu open={popoverOpen} onOpenChange={handleOpenChange}>
172189
<DropdownMenuTrigger
173190
render={
174191
<Item
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
beginSidebarPeek,
4+
cancelSidebarPeek,
5+
endSidebarPeek,
6+
holdSidebarPeek,
7+
releaseSidebarPeek,
8+
useSidebarPeekStore,
9+
} from "./sidebarPeekStore";
10+
11+
const isPeeked = (): boolean => useSidebarPeekStore.getState().peek;
12+
13+
describe("sidebarPeekStore", () => {
14+
beforeEach(() => {
15+
vi.useFakeTimers();
16+
// Reset shared module-level state (hold flag + hide timer + peek) so each
17+
// case starts clean.
18+
cancelSidebarPeek();
19+
});
20+
21+
afterEach(() => {
22+
cancelSidebarPeek();
23+
vi.useRealTimers();
24+
});
25+
26+
it("endSidebarPeek hides the peek only once the delay elapses", () => {
27+
beginSidebarPeek();
28+
expect(isPeeked()).toBe(true);
29+
30+
endSidebarPeek(200);
31+
expect(isPeeked()).toBe(true);
32+
33+
vi.advanceTimersByTime(200);
34+
expect(isPeeked()).toBe(false);
35+
});
36+
37+
it("keeps the peek open while held, then closes once released", () => {
38+
beginSidebarPeek();
39+
holdSidebarPeek();
40+
41+
endSidebarPeek(0);
42+
vi.runAllTimers();
43+
expect(isPeeked()).toBe(true);
44+
45+
releaseSidebarPeek();
46+
endSidebarPeek(0);
47+
vi.runAllTimers();
48+
expect(isPeeked()).toBe(false);
49+
});
50+
51+
it("holdSidebarPeek cancels a hide that is already pending", () => {
52+
beginSidebarPeek();
53+
endSidebarPeek(200);
54+
holdSidebarPeek();
55+
56+
vi.advanceTimersByTime(200);
57+
expect(isPeeked()).toBe(true);
58+
});
59+
60+
it("cancelSidebarPeek closes immediately and clears the hold", () => {
61+
beginSidebarPeek();
62+
holdSidebarPeek();
63+
64+
cancelSidebarPeek();
65+
expect(isPeeked()).toBe(false);
66+
67+
// The hold was cleared, so normal begin/end behaviour resumes.
68+
beginSidebarPeek();
69+
endSidebarPeek(0);
70+
vi.runAllTimers();
71+
expect(isPeeked()).toBe(false);
72+
});
73+
});

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { create } from "zustand";
33
// Ephemeral hover-peek state for the collapsed sidebar: hovering the left
44
// gutter or the title-bar toggle slides the sidebar out as an overlay, and
55
// leaving hides it. Re-entering any trigger before the hide fires keeps the
6-
// peek alive. Not persisted.
6+
// peek alive. A "hold" keeps it open regardless of pointer position while a
7+
// menu spawned from the sidebar (e.g. the ProjectSwitcher dropdown) is open, so
8+
// moving the pointer toward the menu can't slide the anchor away. Not persisted.
79
interface SidebarPeekStore {
810
peek: boolean;
911
setPeek: (peek: boolean) => void;
@@ -18,6 +20,12 @@ export const useSidebarPeekStore = create<SidebarPeekStore>()((set) => ({
1820
// panel itself) so re-entering any of them keeps the peek alive.
1921
let hideTimer: ReturnType<typeof setTimeout> | null = null;
2022

23+
// While a sidebar-spawned menu is open the peek is "held": endSidebarPeek is a
24+
// 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;
28+
2129
const clearHideTimer = (): void => {
2230
if (hideTimer) {
2331
clearTimeout(hideTimer);
@@ -30,7 +38,21 @@ export function beginSidebarPeek(): void {
3038
useSidebarPeekStore.getState().setPeek(true);
3139
}
3240

41+
// Pin the peek open while a menu spawned from the sidebar is open, and release
42+
// it when that menu closes. Paired open/close calls keep this balanced;
43+
// releasing hands control back to the hover logic, which collapses the peek on
44+
// the next pointer move outside the panel.
45+
export function holdSidebarPeek(): void {
46+
held = true;
47+
clearHideTimer();
48+
}
49+
50+
export function releaseSidebarPeek(): void {
51+
held = false;
52+
}
53+
3354
export function endSidebarPeek(delayMs = 0): void {
55+
if (held) return;
3456
clearHideTimer();
3557
hideTimer = setTimeout(() => {
3658
hideTimer = null;
@@ -39,6 +61,7 @@ export function endSidebarPeek(delayMs = 0): void {
3961
}
4062

4163
export function cancelSidebarPeek(): void {
64+
held = false;
4265
clearHideTimer();
4366
useSidebarPeekStore.getState().setPeek(false);
4467
}

0 commit comments

Comments
 (0)