Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/aura/src/components/item-overlay.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ vaadin-select-item:where([role]) {
background: var(--_highlight-color);
}

/* Suppress hover highlight during safe triangle navigation */
@media (any-hover: hover) {
[safe-triangle-active] > &:not([aria-expanded='true']):not([disabled], [aria-disabled='true']):hover {
background: transparent;
}
}

&[aria-expanded='true']:not(:hover) {
background: var(--vaadin-background-container-strong);
}
Expand Down
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 @@
},
}),
);

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

/** @private */
Expand Down Expand Up @@ -263,7 +269,18 @@
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()) {

Check warning on line 277 in packages/context-menu/src/vaadin-contextmenu-items-mixin.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=vaadin_web-components&issues=AZyLpspUEuG2vT6-Idt1&open=AZyLpspUEuG2vT6-Idt1&pullRequest=11183
this.__safeTriangle.scheduleSwitch(() => {
this.__showSubMenu(event, item);
});
} else {
this.__showSubMenu(event, item);
}
});

overlay.addEventListener('keydown', (event) => {
Expand Down Expand Up @@ -349,6 +366,10 @@
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 @@
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, parentContainer?: 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;
}
223 changes: 223 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,223 @@
/**
* @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 {
#lastX = 0;

#lastY = 0;

#invalidCount = 0;

#lastMoveTime = 0;

#submenuElement = null;

#parentItemElement = null;

#pendingSwitch = null;

#pendingTimeout = null;

#parentContainer = null;

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

if (event.timeStamp - this.#lastMoveTime < THROTTLE_MS) {
return;
}

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

if (this.#lastMoveTime === 0) {
this.#lastMoveTime = event.timeStamp;
this.#lastX = x;
this.#lastY = y;
return;
}
this.#lastMoveTime = event.timeStamp;

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

const dx = x - this.#lastX;
const dy = y - this.#lastY;

if (this.#isPointerAimedAtSubmenu(dx, dy)) {
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
* @param {HTMLElement} [parentContainer] - Optional container element to set safe-triangle-active attribute on
*/
activate(submenuOverlay, parentItem, parentContainer) {
this.#cancelPendingSwitch();
const wasActive = this.#submenuElement !== null;
this.#submenuElement = submenuOverlay;
this.#parentItemElement = parentItem;
this.#invalidCount = 0;
this.#lastMoveTime = 0;
this.#lastX = 0;
this.#lastY = 0;

if (this.#parentContainer && this.#parentContainer !== parentContainer) {
this.#parentContainer.removeAttribute('safe-triangle-active');
}
if (parentContainer) {
this.#parentContainer = parentContainer;
parentContainer.setAttribute('safe-triangle-active', '');
}

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

/**
* Deactivate the safe triangle tracking.
* Should be called when a submenu closes.
*/
deactivate() {
if (this.#parentContainer) {
this.#parentContainer.removeAttribute('safe-triangle-active');
this.#parentContainer = null;
}
if (this.#submenuElement) {
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.#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);
}

#isPointerAimedAtSubmenu(dx, dy) {
const submenuRect = this.#submenuElement.$.overlay.getBoundingClientRect();

// Skip if submenu is not visible
if (submenuRect.width === 0 || submenuRect.height === 0) {
return false;
}

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

// Early exit: moving horizontally away from the submenu
if ((submenuIsRight && dx < -1) || (!submenuIsRight && dx > 1)) {
return false;
}

// Compute the near edge corners of the submenu
const nearX = submenuIsRight ? submenuRect.left : submenuRect.right;

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

// Angle of cursor movement vector
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);

return thetaPointer >= minAngle - TOLERANCE_RAD && thetaPointer <= maxAngle + TOLERANCE_RAD;
}

#cancelPendingSwitch() {
const callback = this.#pendingSwitch;
this.#pendingSwitch = null;
clearTimeout(this.#pendingTimeout);
this.#pendingTimeout = null;
return callback;
}

#executePendingSwitch() {
const callback = this.#cancelPendingSwitch();
if (callback) {
callback();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ snapshots["context-menu items nested"] =
<vaadin-context-menu-list-box
aria-orientation="vertical"
role="menu"
safe-triangle-active=""
>
<vaadin-context-menu-item
aria-haspopup="false"
Expand Down
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
Loading