Skip to content
Open
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
21 changes: 19 additions & 2 deletions packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,35 @@ 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 { 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, useMemo, useState } from "react";
import { type ReactNode, useEffect, 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 handleOpenChange = (next: boolean): void => {
setPopoverOpen(next);
if (next) holdSidebarPeek();
else releaseSidebarPeek();
};
// 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();
const { data: currentUser } = useCurrentUser({ client });
Expand Down Expand Up @@ -168,7 +185,7 @@ export function ProjectSwitcher() {
};

return (
<DropdownMenu open={popoverOpen} onOpenChange={setPopoverOpen}>
<DropdownMenu open={popoverOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
render={
<Item
Expand Down
73 changes: 73 additions & 0 deletions packages/ui/src/features/sidebar/sidebarPeekStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
beginSidebarPeek,
cancelSidebarPeek,
endSidebarPeek,
holdSidebarPeek,
releaseSidebarPeek,
useSidebarPeekStore,
} from "./sidebarPeekStore";

const isPeeked = (): boolean => useSidebarPeekStore.getState().peek;

describe("sidebarPeekStore", () => {
beforeEach(() => {
vi.useFakeTimers();
// Reset shared module-level state (hold flag + hide timer + peek) so each
// case starts clean.
cancelSidebarPeek();
});

afterEach(() => {
cancelSidebarPeek();
vi.useRealTimers();
});

it("endSidebarPeek hides the peek only once the delay elapses", () => {
beginSidebarPeek();
expect(isPeeked()).toBe(true);

endSidebarPeek(200);
expect(isPeeked()).toBe(true);

vi.advanceTimersByTime(200);
expect(isPeeked()).toBe(false);
});

it("keeps the peek open while held, then closes once released", () => {
beginSidebarPeek();
holdSidebarPeek();

endSidebarPeek(0);
vi.runAllTimers();
expect(isPeeked()).toBe(true);

releaseSidebarPeek();
endSidebarPeek(0);
vi.runAllTimers();
expect(isPeeked()).toBe(false);
});

it("holdSidebarPeek cancels a hide that is already pending", () => {
beginSidebarPeek();
endSidebarPeek(200);
holdSidebarPeek();

vi.advanceTimersByTime(200);
expect(isPeeked()).toBe(true);
});

it("cancelSidebarPeek closes immediately and clears the hold", () => {
beginSidebarPeek();
holdSidebarPeek();

cancelSidebarPeek();
expect(isPeeked()).toBe(false);

// The hold was cleared, so normal begin/end behaviour resumes.
beginSidebarPeek();
endSidebarPeek(0);
vi.runAllTimers();
expect(isPeeked()).toBe(false);
});
});
25 changes: 24 additions & 1 deletion packages/ui/src/features/sidebar/sidebarPeekStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { create } from "zustand";
// Ephemeral hover-peek state for the collapsed sidebar: hovering the left
// gutter or the title-bar toggle slides the sidebar out as an overlay, and
// leaving hides it. Re-entering any trigger before the hide fires keeps the
// peek alive. Not persisted.
// peek alive. A "hold" keeps it open regardless of pointer position while a
// menu spawned from the sidebar (e.g. the ProjectSwitcher dropdown) is open, so
// moving the pointer toward the menu can't slide the anchor away. Not persisted.
interface SidebarPeekStore {
peek: boolean;
setPeek: (peek: boolean) => void;
Expand All @@ -18,6 +20,12 @@ export const useSidebarPeekStore = create<SidebarPeekStore>()((set) => ({
// panel itself) so re-entering any of them keeps the peek alive.
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;

const clearHideTimer = (): void => {
if (hideTimer) {
clearTimeout(hideTimer);
Expand All @@ -30,7 +38,21 @@ export function beginSidebarPeek(): void {
useSidebarPeekStore.getState().setPeek(true);
}

// Pin the peek open while a menu spawned from the sidebar is open, and release
// it when that menu closes. Paired open/close calls keep this balanced;
// 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;
clearHideTimer();
}

export function releaseSidebarPeek(): void {
held = false;
}

export function endSidebarPeek(delayMs = 0): void {
if (held) return;
clearHideTimer();
hideTimer = setTimeout(() => {
hideTimer = null;
Expand All @@ -39,6 +61,7 @@ export function endSidebarPeek(delayMs = 0): void {
}

export function cancelSidebarPeek(): void {
held = false;
clearHideTimer();
useSidebarPeekStore.getState().setPeek(false);
}
Loading