Skip to content

Commit 485b826

Browse files
committed
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
1 parent 73b695c commit 485b826

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)