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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user