Skip to content
27 changes: 26 additions & 1 deletion packages/context-menu/src/vaadin-contextmenu-items-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { isTouch } from '@vaadin/component-base/src/browser-utils.js';
import { SafeTriangleController } from './vaadin-safe-triangle-controller.js';

/**
* @polymerMixin
Expand Down Expand Up @@ -163,6 +164,11 @@ export const ItemsMixin = (superClass) =>
},
}),
);

// Activate safe triangle tracking for the newly opened submenu
if (this.__safeTriangle) {
this.__safeTriangle.activate(subMenuOverlay, itemElement);
}
}

/** @private */
Expand Down Expand Up @@ -263,7 +269,18 @@ export const ItemsMixin = (superClass) =>
return;
}

this.__showSubMenu(event);
// Extract item reference eagerly since composedPath() is only valid synchronously
const item = event.composedPath().find((node) => node.localName === `${this._tagNamePrefix}-item`);

// If a submenu is open and the safe triangle indicates the user is
// aiming at it, defer the switch instead of switching immediately.
if (this._subMenu.opened && this.__safeTriangle && this.__safeTriangle.shouldKeepOpen()) {
this.__safeTriangle.scheduleSwitch(() => {
this.__showSubMenu(event, item);
});
} else {
this.__showSubMenu(event, item);
}
});

overlay.addEventListener('keydown', (event) => {
Expand Down Expand Up @@ -349,6 +366,10 @@ export const ItemsMixin = (superClass) =>
if (expandedItem) {
this.__updateExpanded(expandedItem, false);
}
// Deactivate safe triangle tracking when submenu closes
if (this.__safeTriangle) {
this.__safeTriangle.deactivate();
}
}
});

Expand Down Expand Up @@ -472,6 +493,10 @@ export const ItemsMixin = (superClass) =>
this._subMenu = subMenu;
this.appendChild(subMenu);

if (!isTouch) {
this.__safeTriangle = new SafeTriangleController();
}

requestAnimationFrame(() => {
this.__openListenerActive = true;
});
Expand Down
39 changes: 39 additions & 0 deletions packages/context-menu/src/vaadin-safe-triangle-controller.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license
* Copyright (c) 2016 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/

/**
* A controller that implements the "safe triangle" pattern for submenu navigation.
*
* When a submenu is open, moving the mouse diagonally from a parent item toward the
* submenu can cause the cursor to pass over sibling items, which would normally close
* the current submenu. This controller detects whether the cursor is aimed at the open
* submenu using atan2 angle comparison, and prevents premature submenu switching.
*/
export class SafeTriangleController {
/**
* Activate the safe triangle tracking for the given submenu overlay.
* Should be called when a submenu opens.
*/
activate(submenuOverlay: HTMLElement, parentItem: HTMLElement): void;

/**
* Deactivate the safe triangle tracking.
* Should be called when a submenu closes.
*/
deactivate(): void;

/**
* Check whether the submenu should be kept open based on pointer movement.
* Returns true if the user appears to be aiming at the submenu.
*/
shouldKeepOpen(): boolean;

/**
* Schedule a deferred submenu switch. If the user moves outside the safe
* triangle before the callback fires, the callback will execute.
*/
scheduleSwitch(callback: () => void): void;
}
219 changes: 219 additions & 0 deletions packages/context-menu/src/vaadin-safe-triangle-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* @license
* Copyright (c) 2016 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/

const TOLERANCE_RAD = 15 * (Math.PI / 180);
const INVALID_THRESHOLD = 2;
const THROTTLE_MS = 16;
const FALLBACK_TIMEOUT_MS = 400;

/**
* A controller that implements the "safe triangle" pattern for submenu navigation.
*
* When a submenu is open, moving the mouse diagonally from a parent item toward the
* submenu can cause the cursor to pass over sibling items, which would normally close
* the current submenu. This controller detects whether the cursor is aimed at the open
* submenu using atan2 angle comparison, and prevents premature submenu switching.
*
* The approach is based on React Aria's pointer-friendly submenu implementation:
* - Computes angles from cursor position to the near corners of the submenu
* - If the cursor movement angle falls within the cone (with tolerance), the user
* is aiming at the submenu
* - Requires multiple consecutive "miss" movements before allowing a switch
* (accommodates motor impairments and tremors)
* - Only active for pointer/mouse input; ignored for touch and pen
*/
export class SafeTriangleController {
#hasLastPosition = false;

#lastX = 0;

#lastY = 0;

#invalidCount = 0;

#active = false;

#lastMoveTime = 0;

#submenuElement = null;

#parentItemElement = null;

#pendingSwitch = null;

#pendingTimeout = null;

#onPointerMove = (event) => {
// Only handle mouse pointer, not touch or pen
if (event.pointerType === 'touch' || event.pointerType === 'pen') {
return;
}

const now = performance.now();
if (now - this.#lastMoveTime < THROTTLE_MS) {
return;
}
this.#lastMoveTime = now;

const x = event.clientX;
const y = event.clientY;

if (!this.#hasLastPosition) {
this.#hasLastPosition = true;
this.#lastX = x;
this.#lastY = y;
return;
}

if (!this.#submenuElement) {
this.#lastX = x;
this.#lastY = y;
return;
}

const submenuRect = this.#submenuElement.$.overlay.getBoundingClientRect();

// Skip if submenu is not visible
if (submenuRect.width === 0 || submenuRect.height === 0) {
this.#lastX = x;
this.#lastY = y;
return;
}

// Determine submenu direction from actual position, not RTL flag
const parentRect = this.#parentItemElement.getBoundingClientRect();
const submenuIsRight = submenuRect.left >= parentRect.left;

const dx = x - this.#lastX;

// Early exit: moving horizontally away from the submenu
if ((submenuIsRight && dx < -1) || (!submenuIsRight && dx > 1)) {
this.#invalidCount += 1;
} else {
// Compute the near edge corners of the submenu
const nearX = submenuIsRight ? submenuRect.left : submenuRect.right;
const topY = submenuRect.top;
const bottomY = submenuRect.bottom;

// Angle from previous cursor position to the two submenu corners
const thetaTop = Math.atan2(topY - this.#lastY, nearX - this.#lastX);
const thetaBottom = Math.atan2(bottomY - this.#lastY, nearX - this.#lastX);

// Angle of cursor movement vector
const dy = y - this.#lastY;
const thetaPointer = Math.atan2(dy, dx);

// Determine the angular bounds (top and bottom may swap depending on direction)
const minAngle = Math.min(thetaTop, thetaBottom);
const maxAngle = Math.max(thetaTop, thetaBottom);

if (thetaPointer >= minAngle - TOLERANCE_RAD && thetaPointer <= maxAngle + TOLERANCE_RAD) {
// Cursor is aimed at the submenu
this.#invalidCount = 0;
} else {
this.#invalidCount += 1;
}
}

this.#lastX = x;
this.#lastY = y;

// If the user has moved outside the safe triangle enough times, execute pending switch
if (this.#invalidCount >= INVALID_THRESHOLD && this.#pendingSwitch) {
this.#executePendingSwitch();
}
};

/**
* Activate the safe triangle tracking for the given submenu overlay.
* Should be called when a submenu opens.
*
* @param {HTMLElement} submenuOverlay - The submenu overlay element
* @param {HTMLElement} parentItem - The parent menu item that triggered the submenu
*/
activate(submenuOverlay, parentItem) {
this.#cancelPendingSwitch();
this.#submenuElement = submenuOverlay;
this.#parentItemElement = parentItem;
this.#invalidCount = 0;
this.#lastMoveTime = 0;
this.#hasLastPosition = false;
this.#lastX = 0;
this.#lastY = 0;

if (!this.#active) {
this.#active = true;
document.addEventListener('pointermove', this.#onPointerMove);
}
}

/**
* Deactivate the safe triangle tracking.
* Should be called when a submenu closes.
*/
deactivate() {
if (this.#active) {
this.#active = false;
document.removeEventListener('pointermove', this.#onPointerMove);
}
this.#submenuElement = null;
this.#parentItemElement = null;
this.#invalidCount = 0;
this.#cancelPendingSwitch();
}

/**
* Check whether the submenu should be kept open based on pointer movement.
* Returns true if the user appears to be aiming at the submenu.
*
* @return {boolean}
*/
shouldKeepOpen() {
if (!this.#active || !this.#submenuElement) {
return false;
}
// Only block switches if we've actually tracked pointer movement.
// Without movement data, we can't determine intent.
if (this.#lastMoveTime === 0) {
return false;
}
return this.#invalidCount < INVALID_THRESHOLD;
}

/**
* Schedule a deferred submenu switch. If the user moves outside the safe
* triangle before the callback fires, the callback will execute.
*
* @param {Function} callback - The function to call when the switch should happen
*/
scheduleSwitch(callback) {
this.#cancelPendingSwitch();
this.#pendingSwitch = callback;
// Fallback: if the user stops moving entirely, execute the switch
// after a timeout so the submenu doesn't stay stuck indefinitely.
this.#pendingTimeout = setTimeout(() => {
this.#executePendingSwitch();
}, FALLBACK_TIMEOUT_MS);
}

#cancelPendingSwitch() {
this.#pendingSwitch = null;
if (this.#pendingTimeout) {
clearTimeout(this.#pendingTimeout);
this.#pendingTimeout = null;
}
}

#executePendingSwitch() {
const callback = this.#pendingSwitch;
this.#pendingSwitch = null;
clearTimeout(this.#pendingTimeout);
this.#pendingTimeout = null;
if (callback) {
callback();
}
}
}
11 changes: 11 additions & 0 deletions packages/context-menu/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ export function getSubMenu(menu) {
return menu.querySelector(':scope > vaadin-context-menu[slot="submenu"]');
}

export function pointerMove(x, y) {
document.dispatchEvent(
new PointerEvent('pointermove', {
clientX: x,
clientY: y,
bubbles: true,
pointerType: 'mouse',
}),
);
}

export async function openSubMenus(menu) {
await oneEvent(menu._overlayElement, 'vaadin-overlay-open');
const itemElement = menu.querySelector(':scope > [slot="overlay"] [aria-haspopup="true"]');
Expand Down
Loading