Give cascading menus hover intent with a safe triangle

Add CascadeMenu/CascadeRow: a menu whose rows reveal a side submenu on
hover with an open delay, a forgiving close delay, and a cursor→submenu
safe triangle — while the cursor sweeps toward an open submenu, hovers
over sibling rows are ignored, so a natural diagonal move never snaps the
target shut. The triangle geometry (pointInTriangle) is unit-tested.

Apply it to the org switcher: each organization reveals its actions
(switch / delete) on hover instead of a hidden hover-only "x", while the
destructive delete keeps its deliberate, non-hover-dismissible confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.8 committed 2026-06-10 20:26:35 -04:00
1 parent e58d23bfdc
commit f5aab15920
5 files changed
+305 -68

No files matched your search

@@ -170,6 +170,28 @@
.menu-row .menu-check { margin-left: auto; color: var(--accent); display: inline-flex; }
.menu-divider { height: 1px; background: var(--border-soft); margin: var(--sp-1) 0; }
/* Cascading submenu (hover-intent, safe-triangle — see CascadeMenu.tsx). */
.cascade-row-anchor { position: relative; }
.cascade-submenu {
position: absolute;
top: -5px;
left: 100%;
margin-left: 4px;
z-index: 40;
min-width: 200px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
padding: var(--sp-1);
display: flex;
flex-direction: column;
gap: 2px;
animation: menu-enter var(--motion-fast) var(--ease-out-expo);
}
.org-menu-chevron { margin-left: auto; color: var(--text-3); display: inline-flex; flex-shrink: 0; }
.org-menu-row.active .org-menu-chevron { color: var(--text-2); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--r-full); }
::-webkit-scrollbar-track { background: transparent; }
@@ -0,0 +1,188 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
/** Hover-intent timings. Open is brisk; close is forgiving so the cursor can
* cross the gap between a row and its submenu without it collapsing. */
const OPEN_DELAY_MS = 90;
const CLOSE_DELAY_MS = 320;
interface Point {
x: number;
y: number;
}
interface CascadeController {
openId: string | null;
/** A row asks to become the open one (on hover). Honors the safe triangle. */
hoverRow: (id: string) => void;
/** A row/submenu cancels the pending close (cursor is still inside the menu). */
keepOpen: () => void;
/** The cursor left the whole menu — close after the grace delay. */
scheduleClose: () => void;
/** Submenus register their element so the safe triangle can read its edge. */
registerSubmenu: (id: string, element: HTMLElement | null) => void;
closeNow: () => void;
}
const CascadeContext = createContext<CascadeController | null>(null);
function sign(a: Point, b: Point, c: Point): number {
return (a.x - c.x) * (b.y - c.y) - (b.x - c.x) * (a.y - c.y);
}
/** Is point p inside triangle a-b-c (inclusive of edges)? Exported for tests. */
export function pointInTriangle(p: Point, a: Point, b: Point, c: Point): boolean {
const d1 = sign(p, a, b);
const d2 = sign(p, b, c);
const d3 = sign(p, c, a);
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
return !(hasNeg && hasPos);
}
/**
* A menu whose rows can each reveal a side submenu on hover. The container owns
* the single open row and the cursor→submenu "safe triangle": when a submenu is
* open and the cursor is travelling toward it, hovers over sibling rows are
* ignored, so a natural diagonal move never snaps the target submenu shut.
*/
export function CascadeMenu({ children, className }: { children: ReactNode; className?: string }) {
const [openId, setOpenId] = useState<string | null>(null);
const openIdRef = useRef<string | null>(null);
const pointer = useRef<Point>({ x: 0, y: 0 });
const priorPointer = useRef<Point>({ x: 0, y: 0 });
const submenus = useRef<Map<string, HTMLElement>>(new Map());
const openTimer = useRef<number | null>(null);
const closeTimer = useRef<number | null>(null);
const setOpen = useCallback((id: string | null) => {
openIdRef.current = id;
setOpenId(id);
}, []);
const clearOpenTimer = () => {
if (openTimer.current !== null) {
window.clearTimeout(openTimer.current);
openTimer.current = null;
}
};
const clearCloseTimer = () => {
if (closeTimer.current !== null) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
/** True when the cursor is sweeping toward the currently open submenu. The
* apex is the prior cursor sample; the base is the submenu's near (left) edge. */
const movingTowardSubmenu = (): boolean => {
const current = openIdRef.current;
if (!current) return false;
const element = submenus.current.get(current);
if (!element) return false;
const rect = element.getBoundingClientRect();
const apex = priorPointer.current;
const top: Point = { x: rect.left, y: rect.top };
const bottom: Point = { x: rect.left, y: rect.bottom };
return pointInTriangle(pointer.current, apex, top, bottom);
};
const hoverRow = useCallback((id: string) => {
clearCloseTimer();
if (id === openIdRef.current) {
clearOpenTimer();
return;
}
// Defer switching while the cursor is en route to the open submenu.
if (openIdRef.current && movingTowardSubmenu()) return;
clearOpenTimer();
openTimer.current = window.setTimeout(() => setOpen(id), OPEN_DELAY_MS);
}, [setOpen]);
const keepOpen = useCallback(() => {
clearCloseTimer();
}, []);
const scheduleClose = useCallback(() => {
clearOpenTimer();
clearCloseTimer();
closeTimer.current = window.setTimeout(() => setOpen(null), CLOSE_DELAY_MS);
}, [setOpen]);
const closeNow = useCallback(() => {
clearOpenTimer();
clearCloseTimer();
setOpen(null);
}, [setOpen]);
const registerSubmenu = useCallback((id: string, element: HTMLElement | null) => {
if (element) submenus.current.set(id, element);
else submenus.current.delete(id);
}, []);
useEffect(() => () => { clearOpenTimer(); clearCloseTimer(); }, []);
const controller: CascadeController = { openId, hoverRow, keepOpen, scheduleClose, registerSubmenu, closeNow };
return (
<div
className={className}
onMouseMove={(event) => {
priorPointer.current = pointer.current;
pointer.current = { x: event.clientX, y: event.clientY };
}}
onMouseLeave={scheduleClose}
>
<CascadeContext.Provider value={controller}>{children}</CascadeContext.Provider>
</div>
);
}
/**
* A row that reveals `submenu` to its right on hover. Composes with sibling rows
* through the surrounding {@link CascadeMenu} so only one submenu shows and the
* safe triangle governs switching.
*/
export function CascadeRow({
id,
children,
submenu,
}: {
id: string;
children: ReactNode;
submenu: (close: () => void) => ReactNode;
}) {
const controller = useContext(CascadeContext);
if (!controller) throw new Error("CascadeRow must be used inside a CascadeMenu.");
const open = controller.openId === id;
const submenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
controller.registerSubmenu(id, open ? submenuRef.current : null);
return () => controller.registerSubmenu(id, null);
}, [controller, id, open]);
return (
<div className="cascade-row-anchor" onMouseEnter={() => controller.hoverRow(id)}>
{children}
{open && (
<div
ref={submenuRef}
role="menu"
className="cascade-submenu"
onMouseEnter={controller.keepOpen}
onMouseLeave={controller.scheduleClose}
>
{submenu(controller.closeNow)}
</div>
)}
</div>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { AppSnapshot, OrganizationRecord } from "../../../shared/domain";
import { Icon } from "./Icon";
import { CascadeMenu, CascadeRow } from "./CascadeMenu";
function orgInitial(name: string): string {
return name.trim().charAt(0).toUpperCase();
@@ -33,8 +34,8 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const initial = orgInitial(active.name);
const orgs = snapshot.organizations;
const confirmingOrg = confirmingDelete ? orgs.find((org) => org.id === confirmingDelete) ?? null : null;
const handleSelect = (orgId: string) => {
setOpen(false);
@@ -58,96 +59,90 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<span className="brand-mark">{initial}</span>
<span className="brand-mark">{orgInitial(active.name)}</span>
<span className="brand-name">{active.name}</span>
<Icon name="chevronDown" size={12} color="var(--text-3)" />
<Icon name="chevronDown" size={14} color="var(--text-3)" />
</button>
{open && (
{open && (confirmingOrg ? (
<div role="menu" className="org-menu">
<div className="org-menu-confirm">
<div className="org-menu-confirm-title">Delete “{confirmingOrg.name}”?</div>
<div className="org-menu-confirm-hint">All agents, work, chats, and files are permanently removed.</div>
<div className="org-menu-confirm-actions">
<button type="button" className="org-menu-confirm-no" onClick={() => setConfirmingDelete(null)}>Cancel</button>
<button type="button" className="org-menu-confirm-yes" onClick={() => handleDelete(confirmingOrg.id)}>Delete</button>
</div>
</div>
</div>
) : (
<CascadeMenu className="org-menu">
<div className="org-menu-section-label">Organizations</div>
{orgs.map((org) => (
<OrgRow
<CascadeRow
key={org.id}
org={org}
active={org.id === activeOrgId}
onSelect={() => handleSelect(org.id)}
confirming={confirmingDelete === org.id}
onAskDelete={() => setConfirmingDelete(org.id)}
onCancelDelete={() => setConfirmingDelete(null)}
onConfirmDelete={() => handleDelete(org.id)}
/>
id={org.id}
submenu={(close) => (
<OrgActions
org={org}
isActive={org.id === activeOrgId}
onSwitch={() => { close(); handleSelect(org.id); }}
onDelete={() => { close(); setConfirmingDelete(org.id); }}
/>
)}
>
<button
type="button"
role="menuitem"
className={`org-menu-row ${org.id === activeOrgId ? "active" : ""}`.trim()}
onClick={() => handleSelect(org.id)}
>
<span className="brand-mark sm">{orgInitial(org.name)}</span>
<span className="org-menu-name">{org.name}</span>
{org.id === activeOrgId && <Icon name="check" size={14} color="var(--text-1)" />}
<span className="org-menu-chevron"><Icon name="chevron" size={14} /></span>
</button>
</CascadeRow>
))}
<div className="org-menu-divider" />
<button
type="button"
role="menuitem"
className="org-menu-action"
onClick={() => {
setOpen(false);
setConfirmingDelete(null);
onNew();
}}
onClick={() => { setOpen(false); setConfirmingDelete(null); onNew(); }}
>
<Icon name="plus" size={13} color="var(--text-2)" />
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="plus" size={16} color="var(--text-2)" /></span>
<span>New organization</span>
</button>
</div>
)}
</CascadeMenu>
))}
</div>
);
}
function OrgRow({
function OrgActions({
org,
active,
onSelect,
confirming,
onAskDelete,
onCancelDelete,
onConfirmDelete,
isActive,
onSwitch,
onDelete,
}: {
org: OrganizationRecord;
active: boolean;
onSelect: () => void;
confirming: boolean;
onAskDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
isActive: boolean;
onSwitch: () => void;
onDelete: () => void;
}) {
const initial = orgInitial(org.name);
if (confirming) {
return (
<div className="org-menu-confirm">
<div className="org-menu-confirm-title">Delete “{org.name}”?</div>
<div className="org-menu-confirm-hint">All agents, work, chats, and files are permanently removed.</div>
<div className="org-menu-confirm-actions">
<button type="button" className="org-menu-confirm-no" onClick={onCancelDelete}>Cancel</button>
<button type="button" className="org-menu-confirm-yes" onClick={onConfirmDelete}>Delete</button>
</div>
</div>
);
}
return (
<button
type="button"
role="menuitem"
className={`org-menu-row ${active ? "active" : ""}`.trim()}
onClick={onSelect}
>
<span className="brand-mark sm">{initial}</span>
<span className="org-menu-name">{org.name}</span>
{active && <Icon name="check" size={12} color="var(--text-1)" />}
<span
role="button"
aria-label={`Delete ${org.name}`}
className="org-menu-delete"
onClick={(event) => {
event.stopPropagation();
onAskDelete();
}}
>
<Icon name="x" size={12} color="var(--text-3)" />
</span>
</button>
<>
<div className="org-menu-section-label">{org.name}</div>
{!isActive && (
<button type="button" role="menuitem" className="org-menu-action" onClick={onSwitch}>
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="arrowRight" size={16} color="var(--text-2)" /></span>
<span>Switch to this org</span>
</button>
)}
<button type="button" role="menuitem" className="org-menu-action danger" onClick={onDelete}>
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="x" size={16} color="var(--red)" /></span>
<span>Delete organization</span>
</button>
</>
);
}
@@ -342,6 +342,8 @@ a { color: inherit; }
transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out);
}
.org-menu-action:hover { background: var(--hover); color: var(--text-1); }
.org-menu-action.danger { color: var(--red); }
.org-menu-action.danger:hover { background: rgba(210, 59, 59, 0.1); color: var(--red); }
.brand-mark.sm {
width: 20px;
height: 20px;
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { pointInTriangle } from "../../src/renderer/src/shared/CascadeMenu";
// The safe triangle: apex at the cursor's prior position, base on the open
// submenu's near (left) edge. A submenu sitting to the right spanning y∈[-20,40]
// at x=100, with the cursor having just been at the origin.
const apex = { x: 0, y: 0 };
const top = { x: 100, y: -20 };
const bottom = { x: 100, y: 40 };
describe("safe triangle (cursor → submenu)", () => {
test("a cursor sweeping diagonally toward the submenu stays inside", () => {
// Midway to the submenu, within the widening cone — switching is deferred.
expect(pointInTriangle({ x: 50, y: 5 }, apex, top, bottom)).toBe(true);
expect(pointInTriangle({ x: 90, y: 30 }, apex, top, bottom)).toBe(true);
});
test("a cursor that drifts off-course toward a sibling row falls outside", () => {
// At x=50 the cone only spans y∈[-10,20]; y=30 is below it — let the sibling win.
expect(pointInTriangle({ x: 50, y: 30 }, apex, top, bottom)).toBe(false);
// Straight down from the apex, away from the submenu entirely.
expect(pointInTriangle({ x: 5, y: 35 }, apex, top, bottom)).toBe(false);
});
test("the apex and the submenu corners are on the triangle", () => {
expect(pointInTriangle(apex, apex, top, bottom)).toBe(true);
expect(pointInTriangle(top, apex, top, bottom)).toBe(true);
expect(pointInTriangle(bottom, apex, top, bottom)).toBe(true);
});
});