Skip to content

Commit 65c8cac

Browse files
srdavocharlesvien
andauthored
fix(sidebar): reveal collapsed nav by edge proximity, close only on directional exit (#3367)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent 2f0a348 commit 65c8cac

5 files changed

Lines changed: 138 additions & 33 deletions

File tree

packages/ui/src/features/canvas/components/ChannelsSidebar.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
1919
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
2020
import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace";
21+
import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
2122
import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar";
2223
import { navigateToArchived } from "@posthog/ui/router/navigationBridge";
2324
import { Box, Flex } from "@radix-ui/themes";
@@ -50,10 +51,15 @@ export function ChannelsSidebar() {
5051
setOpenAuto(hasCompletedOnboarding || Object.keys(workspaces).length > 0);
5152
}, [workspacesFetched, workspaces, hasCompletedOnboarding, setOpenAuto]);
5253

53-
// Hover-reveal while collapsed: the left gutter / title-bar toggle set peek,
54-
// and the panel keeps it alive under the pointer. Any open (click, Cmd+B)
55-
// makes the peek redundant — drop it so the overlay state can't linger.
5654
const peek = useSidebarPeekStore((s) => s.peek);
55+
useSidebarEdgeHoverPeek({
56+
enabled: !open && !isResizing,
57+
peeked: peek,
58+
side: "left",
59+
width,
60+
onReveal: beginSidebarPeek,
61+
onClose: () => endSidebarPeek(),
62+
});
5763
useEffect(() => {
5864
if (open) cancelSidebarPeek();
5965
}, [open]);

packages/ui/src/primitives/ResizableSidebar.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants";
2+
import { PEEK_CLOSE_MARGIN } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
23
import { Box, Flex } from "@radix-ui/themes";
34
import React from "react";
45

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

@@ -227,10 +225,6 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
227225
>
228226
<Flex
229227
direction="column"
230-
onMouseEnter={isOverlay ? onPeekEnter : undefined}
231-
onMouseLeave={
232-
isOverlay && !isResizing ? () => onPeekLeave?.() : undefined
233-
}
234228
style={{
235229
width: `${width}px`,
236230
...(isOverlay
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
PEEK_CLOSE_MARGIN,
3+
PEEK_REVEAL_THRESHOLD,
4+
shouldCloseOnExit,
5+
shouldRevealOnEdge,
6+
} from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek";
7+
import { describe, expect, it } from "vitest";
8+
9+
describe("shouldRevealOnEdge", () => {
10+
const threshold = PEEK_REVEAL_THRESHOLD;
11+
12+
it.each([
13+
["crosses into the zone from outside", 10, false, true],
14+
["already inside the zone (no re-trigger)", 10, true, false],
15+
["outside the zone", 100, false, false],
16+
["flick from outside straight to the edge in one sample", 0, false, true],
17+
["exactly on the threshold, crossing in", threshold, false, true],
18+
["just past the threshold", threshold + 1, false, false],
19+
])("%s", (_name, pointer, wasInside, expected) => {
20+
expect(shouldRevealOnEdge({ pointer, wasInside, threshold })).toBe(
21+
expected,
22+
);
23+
});
24+
});
25+
26+
describe("shouldCloseOnExit", () => {
27+
const margin = PEEK_CLOSE_MARGIN;
28+
29+
it.each([
30+
["inside the panel", 100, 240, false],
31+
["between the panel edge and the margin", 280, 240, false],
32+
["exactly on the far edge (right edge)", 240, 240, false],
33+
["exactly on the close boundary", 240 + margin, 240, false],
34+
["past the close boundary into content", 240 + margin + 1, 240, true],
35+
["stays open at the left edge / off-window", 0, 240, false],
36+
["wide panel still open before its boundary", 400 + margin, 400, false],
37+
["wide panel closes past its boundary", 400 + margin + 1, 400, true],
38+
])("%s", (_name, pointer, width, expected) => {
39+
expect(shouldCloseOnExit({ pointer, width, margin })).toBe(expected);
40+
});
41+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect, useRef } from "react";
2+
3+
export const PEEK_REVEAL_THRESHOLD = 24;
4+
export const PEEK_CLOSE_MARGIN = 64;
5+
6+
export function shouldRevealOnEdge({
7+
pointer,
8+
wasInside,
9+
threshold,
10+
}: {
11+
pointer: number;
12+
wasInside: boolean;
13+
threshold: number;
14+
}): boolean {
15+
return pointer <= threshold && !wasInside;
16+
}
17+
18+
export function shouldCloseOnExit({
19+
pointer,
20+
width,
21+
margin,
22+
}: {
23+
pointer: number;
24+
width: number;
25+
margin: number;
26+
}): boolean {
27+
return pointer > width + margin;
28+
}
29+
30+
interface UseSidebarEdgeHoverPeekOptions {
31+
enabled: boolean;
32+
peeked: boolean;
33+
side: "left" | "right";
34+
width: number;
35+
onReveal: () => void;
36+
onClose: () => void;
37+
}
38+
39+
export function useSidebarEdgeHoverPeek({
40+
enabled,
41+
peeked,
42+
side,
43+
width,
44+
onReveal,
45+
onClose,
46+
}: UseSidebarEdgeHoverPeekOptions): void {
47+
const stateRef = useRef({ enabled, peeked, side, width, onReveal, onClose });
48+
stateRef.current = { enabled, peeked, side, width, onReveal, onClose };
49+
50+
useEffect(() => {
51+
let wasInside = false;
52+
53+
const handleMouseMove = (e: MouseEvent) => {
54+
const state = stateRef.current;
55+
const pointer =
56+
state.side === "left" ? e.clientX : window.innerWidth - e.clientX;
57+
58+
if (state.enabled) {
59+
if (state.peeked) {
60+
if (
61+
shouldCloseOnExit({
62+
pointer,
63+
width: state.width,
64+
margin: PEEK_CLOSE_MARGIN,
65+
})
66+
) {
67+
state.onClose();
68+
}
69+
} else if (
70+
shouldRevealOnEdge({
71+
pointer,
72+
wasInside,
73+
threshold: PEEK_REVEAL_THRESHOLD,
74+
})
75+
) {
76+
state.onReveal();
77+
}
78+
}
79+
80+
wasInside = pointer <= PEEK_REVEAL_THRESHOLD;
81+
};
82+
83+
document.addEventListener("mousemove", handleMouseMove);
84+
return () => document.removeEventListener("mousemove", handleMouseMove);
85+
}, []);
86+
}

packages/ui/src/router/routes/__root.tsx

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"
4545
import {
4646
beginSidebarPeek,
4747
cancelSidebarPeek,
48-
endSidebarPeek,
4948
useSidebarPeekStore,
5049
} from "@posthog/ui/features/sidebar/sidebarPeekStore";
5150
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
@@ -374,12 +373,6 @@ function RootLayout() {
374373
onMouseEnter={() => {
375374
if (!sidebarOpen) beginSidebarPeek();
376375
}}
377-
onMouseLeave={() => {
378-
// Grace only here: the pointer needs time to travel from
379-
// the title-bar button down into the nav. Leaving the nav
380-
// itself hides immediately.
381-
if (!sidebarOpen) endSidebarPeek(300);
382-
}}
383376
>
384377
{sidebarOpen ? (
385378
<SidebarClose size={10} />
@@ -438,21 +431,6 @@ function RootLayout() {
438431
</Flex>
439432
<ConnectivityBanner />
440433
<Flex flexGrow="1" overflow="hidden" className="relative">
441-
{/* Invisible hover gutter: while the sidebar is collapsed, resting
442-
the pointer on the window's left edge peeks the sidebar out as an
443-
overlay. The panel (z-50) slides over this strip, so its own
444-
hover handlers take over keeping the peek alive. */}
445-
{!sidebarOpen && (
446-
<Box
447-
className="absolute inset-y-0 left-0 z-40 w-2"
448-
onMouseEnter={() => {
449-
// A drag-to-close sweeps the pointer through this strip —
450-
// peeking then would fight the drag.
451-
if (!sidebarIsResizing) beginSidebarPeek();
452-
}}
453-
onMouseLeave={() => endSidebarPeek()}
454-
/>
455-
)}
456434
{/* Scrim under the peeked nav: dims the content while the overlay is
457435
out. Purely visual (pointer-transparent) and paired with the
458436
panel's slide — same 200ms ease-out — so they read as one unit. */}

0 commit comments

Comments
 (0)