Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
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
12 changes: 9 additions & 3 deletions packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace";
import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar";
import { navigateToArchived } from "@posthog/ui/router/navigationBridge";
import { Box, Flex } from "@radix-ui/themes";
Expand Down Expand Up @@ -50,10 +51,15 @@ export function ChannelsSidebar() {
setOpenAuto(hasCompletedOnboarding || Object.keys(workspaces).length > 0);
}, [workspacesFetched, workspaces, hasCompletedOnboarding, setOpenAuto]);

// Hover-reveal while collapsed: the left gutter / title-bar toggle set peek,
// and the panel keeps it alive under the pointer. Any open (click, Cmd+B)
// makes the peek redundant — drop it so the overlay state can't linger.
const peek = useSidebarPeekStore((s) => s.peek);
useSidebarEdgeHoverPeek({
enabled: !open && !isResizing,
peeked: peek,
side: "left",
width,
onReveal: beginSidebarPeek,
onClose: () => endSidebarPeek(),
});
useEffect(() => {
if (open) cancelSidebarPeek();
}, [open]);
Expand Down
10 changes: 2 additions & 8 deletions packages/ui/src/primitives/ResizableSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants";
import { PEEK_CLOSE_MARGIN } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
import { Box, Flex } from "@radix-ui/themes";
import React from "react";

Expand Down Expand Up @@ -146,13 +147,10 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
if (dragEndedClosedRef.current) {
setWidth(dragStartWidthRef.current);
}
// A floating-panel drag suppresses the panel's mouseleave (the pointer
// can travel anywhere mid-drag), so when it ends off-panel, schedule the
// hide the leave would have scheduled.
if (!open && peek) {
const pointer =
side === "left" ? e.clientX : window.innerWidth - e.clientX;
if (pointer > width) onPeekLeave?.();
if (pointer > width + PEEK_CLOSE_MARGIN) onPeekLeave?.();
}
};

Expand Down Expand Up @@ -227,10 +225,6 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
>
<Flex
direction="column"
onMouseEnter={isOverlay ? onPeekEnter : undefined}
onMouseLeave={
isOverlay && !isResizing ? () => onPeekLeave?.() : undefined
}
style={{
width: `${width}px`,
...(isOverlay
Expand Down
41 changes: 41 additions & 0 deletions packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
PEEK_CLOSE_MARGIN,
PEEK_REVEAL_THRESHOLD,
shouldCloseOnExit,
shouldRevealOnEdge,
} from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
import { describe, expect, it } from "vitest";

describe("shouldRevealOnEdge", () => {
const threshold = PEEK_REVEAL_THRESHOLD;

it.each([
["crosses into the zone from outside", 10, false, true],
["already inside the zone (no re-trigger)", 10, true, false],
["outside the zone", 100, false, false],
["flick from outside straight to the edge in one sample", 0, false, true],
["exactly on the threshold, crossing in", threshold, false, true],
["just past the threshold", threshold + 1, false, false],
])("%s", (_name, pointer, wasInside, expected) => {
expect(shouldRevealOnEdge({ pointer, wasInside, threshold })).toBe(
expected,
);
});
});

describe("shouldCloseOnExit", () => {
const margin = PEEK_CLOSE_MARGIN;

it.each([
["inside the panel", 100, 240, false],
["between the panel edge and the margin", 280, 240, false],
["exactly on the far edge (right edge)", 240, 240, false],
["exactly on the close boundary", 240 + margin, 240, false],
["past the close boundary into content", 240 + margin + 1, 240, true],
["stays open at the left edge / off-window", 0, 240, false],
["wide panel still open before its boundary", 400 + margin, 400, false],
["wide panel closes past its boundary", 400 + margin + 1, 400, true],
])("%s", (_name, pointer, width, expected) => {
expect(shouldCloseOnExit({ pointer, width, margin })).toBe(expected);
});
});
86 changes: 86 additions & 0 deletions packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useRef } from "react";

export const PEEK_REVEAL_THRESHOLD = 24;
export const PEEK_CLOSE_MARGIN = 64;

export function shouldRevealOnEdge({
pointer,
wasInside,
threshold,
}: {
pointer: number;
wasInside: boolean;
threshold: number;
}): boolean {
return pointer <= threshold && !wasInside;
}

export function shouldCloseOnExit({
pointer,
width,
margin,
}: {
pointer: number;
width: number;
margin: number;
}): boolean {
return pointer > width + margin;
}

interface UseSidebarEdgeHoverPeekOptions {
enabled: boolean;
peeked: boolean;
side: "left" | "right";
width: number;
onReveal: () => void;
onClose: () => void;
}

export function useSidebarEdgeHoverPeek({
enabled,
peeked,
side,
width,
onReveal,
onClose,
}: UseSidebarEdgeHoverPeekOptions): void {
const stateRef = useRef({ enabled, peeked, side, width, onReveal, onClose });
stateRef.current = { enabled, peeked, side, width, onReveal, onClose };

useEffect(() => {
let wasInside = false;

const handleMouseMove = (e: MouseEvent) => {
const state = stateRef.current;
const pointer =
state.side === "left" ? e.clientX : window.innerWidth - e.clientX;

if (state.enabled) {
if (state.peeked) {
if (
shouldCloseOnExit({
pointer,
width: state.width,
margin: PEEK_CLOSE_MARGIN,
})
) {
state.onClose();
}
} else if (
shouldRevealOnEdge({
pointer,
wasInside,
threshold: PEEK_REVEAL_THRESHOLD,
})
) {
state.onReveal();
}
}

wasInside = pointer <= PEEK_REVEAL_THRESHOLD;
};

document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, []);
}
22 changes: 0 additions & 22 deletions packages/ui/src/router/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"
import {
beginSidebarPeek,
cancelSidebarPeek,
endSidebarPeek,
useSidebarPeekStore,
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
Expand Down Expand Up @@ -374,12 +373,6 @@ function RootLayout() {
onMouseEnter={() => {
if (!sidebarOpen) beginSidebarPeek();
}}
onMouseLeave={() => {
// Grace only here: the pointer needs time to travel from
// the title-bar button down into the nav. Leaving the nav
// itself hides immediately.
if (!sidebarOpen) endSidebarPeek(300);
}}
>
{sidebarOpen ? (
<SidebarClose size={10} />
Expand Down Expand Up @@ -438,21 +431,6 @@ function RootLayout() {
</Flex>
<ConnectivityBanner />
<Flex flexGrow="1" overflow="hidden" className="relative">
{/* Invisible hover gutter: while the sidebar is collapsed, resting
the pointer on the window's left edge peeks the sidebar out as an
overlay. The panel (z-50) slides over this strip, so its own
hover handlers take over keeping the peek alive. */}
{!sidebarOpen && (
<Box
className="absolute inset-y-0 left-0 z-40 w-2"
onMouseEnter={() => {
// A drag-to-close sweeps the pointer through this strip —
// peeking then would fight the drag.
if (!sidebarIsResizing) beginSidebarPeek();
}}
onMouseLeave={() => endSidebarPeek()}
/>
)}
{/* Scrim under the peeked nav: dims the content while the overlay is
out. Purely visual (pointer-transparent) and paired with the
panel's slide — same 200ms ease-out — so they read as one unit. */}
Expand Down
Loading