diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 1d20f79c3d..111ff227f5 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -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"; @@ -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]); diff --git a/packages/ui/src/primitives/ResizableSidebar.tsx b/packages/ui/src/primitives/ResizableSidebar.tsx index 720f9a25bc..6cc504db5d 100644 --- a/packages/ui/src/primitives/ResizableSidebar.tsx +++ b/packages/ui/src/primitives/ResizableSidebar.tsx @@ -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"; @@ -146,13 +147,10 @@ export const ResizableSidebar: React.FC = ({ 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?.(); } }; @@ -227,10 +225,6 @@ export const ResizableSidebar: React.FC = ({ > onPeekLeave?.() : undefined - } style={{ width: `${width}px`, ...(isOverlay diff --git a/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts b/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts new file mode 100644 index 0000000000..45e61f10f1 --- /dev/null +++ b/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.ts b/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.ts new file mode 100644 index 0000000000..40ec2e7ae1 --- /dev/null +++ b/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.ts @@ -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); + }, []); +} diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index f94695bec0..4547d845c4 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -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"; @@ -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 ? ( @@ -438,21 +431,6 @@ function RootLayout() { - {/* 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 && ( - { - // 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. */}