diff --git a/packages/aura/src/components/item-overlay.css b/packages/aura/src/components/item-overlay.css index dc58d5b684b..578a58c3a58 100644 --- a/packages/aura/src/components/item-overlay.css +++ b/packages/aura/src/components/item-overlay.css @@ -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); } diff --git a/packages/context-menu/src/vaadin-contextmenu-items-mixin.js b/packages/context-menu/src/vaadin-contextmenu-items-mixin.js index 815277dff08..1229a56e464 100644 --- a/packages/context-menu/src/vaadin-contextmenu-items-mixin.js +++ b/packages/context-menu/src/vaadin-contextmenu-items-mixin.js @@ -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 @@ -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, this._listBox); + } } /** @private */ @@ -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) => { @@ -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(); + } } }); @@ -472,6 +493,10 @@ export const ItemsMixin = (superClass) => this._subMenu = subMenu; this.appendChild(subMenu); + if (!isTouch) { + this.__safeTriangle = new SafeTriangleController(); + } + requestAnimationFrame(() => { this.__openListenerActive = true; }); diff --git a/packages/context-menu/src/vaadin-safe-triangle-controller.d.ts b/packages/context-menu/src/vaadin-safe-triangle-controller.d.ts new file mode 100644 index 00000000000..78950b511a1 --- /dev/null +++ b/packages/context-menu/src/vaadin-safe-triangle-controller.d.ts @@ -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; +} diff --git a/packages/context-menu/src/vaadin-safe-triangle-controller.js b/packages/context-menu/src/vaadin-safe-triangle-controller.js new file mode 100644 index 00000000000..c48edb6163d --- /dev/null +++ b/packages/context-menu/src/vaadin-safe-triangle-controller.js @@ -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(); + } + } +} diff --git a/packages/context-menu/test/dom/__snapshots__/context-menu.test.snap.js b/packages/context-menu/test/dom/__snapshots__/context-menu.test.snap.js index d0aee9606ae..9942464f714 100644 --- a/packages/context-menu/test/dom/__snapshots__/context-menu.test.snap.js +++ b/packages/context-menu/test/dom/__snapshots__/context-menu.test.snap.js @@ -77,6 +77,7 @@ snapshots["context-menu items nested"] = 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"]'); diff --git a/packages/context-menu/test/items.test.js b/packages/context-menu/test/items.test.js index d24dfe6f3f9..1b9b46e9893 100644 --- a/packages/context-menu/test/items.test.js +++ b/packages/context-menu/test/items.test.js @@ -4,6 +4,7 @@ import { arrowLeftKeyDown, arrowRightKeyDown, arrowUpKeyDown, + aTimeout, enterKeyDown, escKeyDown, fire, @@ -18,7 +19,7 @@ import '../src/vaadin-context-menu.js'; import '@vaadin/item/src/vaadin-item.js'; import '@vaadin/list-box/src/vaadin-list-box.js'; import { isTouch } from '@vaadin/component-base/src/browser-utils.js'; -import { activateItem, getMenuItems, getSubMenu, openMenu } from './helpers.js'; +import { activateItem, getMenuItems, getSubMenu, openMenu, pointerMove } from './helpers.js'; describe('items', () => { let rootMenu, subMenu, target, rootOverlay, subOverlay1; @@ -730,4 +731,148 @@ describe('items', () => { expect(getMenuItems(rootMenu)[1].hasAttribute('focused')).to.be.true; }); }); + + (isTouch ? describe.skip : describe)('safe triangle', () => { + ['ltr', 'rtl'].forEach((dir) => { + describe(dir, () => { + beforeEach(async () => { + if (dir === 'rtl') { + document.documentElement.setAttribute('dir', 'rtl'); + await nextFrame(); + subMenu.close(); + rootMenu.close(); + await nextRender(); + await openMenu(target); + await openMenu(getMenuItems(rootMenu)[0]); + } + }); + + it('should keep submenu open when pointer moves toward it', async () => { + const parentItem = getMenuItems(rootMenu)[0]; + const parentRect = parentItem.getBoundingClientRect(); + const currentSubMenu = getSubMenu(rootMenu); + const subMenuOverlay = currentSubMenu._overlayElement; + const subMenuRect = subMenuOverlay.getBoundingClientRect(); + + if (dir === 'rtl') { + expect(subMenuRect.right).to.be.at.most(parentRect.left + 1); + } + + const startX = parentRect.left + parentRect.width / 2; + const startY = parentRect.top + parentRect.height / 2; + pointerMove(startX, startY); + await aTimeout(20); + + const targetX = subMenuRect.left + subMenuRect.width / 2; + const targetY = subMenuRect.top + subMenuRect.height / 2; + pointerMove((startX + targetX) / 2, (startY + targetY) / 2); + await aTimeout(20); + + activateItem(getMenuItems(rootMenu)[3]); + + expect(currentSubMenu.opened).to.be.true; + expect(getMenuItems(currentSubMenu)[0].textContent).to.equal('foo-0-0'); + }); + }); + }); + + it('should switch submenu when pointer moves away from it', async () => { + const parentItem = getMenuItems(rootMenu)[0]; + const parentRect = parentItem.getBoundingClientRect(); + + // Start from the center of the parent item + const startX = parentRect.left + parentRect.width / 2; + const startY = parentRect.top + parentRect.height / 2; + + // Simulate pointer movement away from the submenu (moving left) + pointerMove(startX, startY); + + await aTimeout(20); + + // Move away (to the left, opposite of submenu) + pointerMove(startX - 50, startY); + + await aTimeout(20); + + // Move away again to exceed the threshold + pointerMove(startX - 100, startY); + + await aTimeout(20); + + // Now hover over the other parent item — should switch + activateItem(getMenuItems(rootMenu)[3]); + expect(getMenuItems(subMenu)[0].textContent).to.equal('foo-3-0'); + }); + + it('should switch submenu after fallback timeout when pointer stops moving', async () => { + const parentItem = getMenuItems(rootMenu)[0]; + const parentRect = parentItem.getBoundingClientRect(); + const subMenuOverlay = subMenu._overlayElement; + const subMenuRect = subMenuOverlay.getBoundingClientRect(); + + // Start from the center of the parent item + const startX = parentRect.left + parentRect.width / 2; + const startY = parentRect.top + parentRect.height / 2; + pointerMove(startX, startY); + + await aTimeout(20); + + // Move diagonally toward the submenu (inside the safe triangle) + const targetX = subMenuRect.left + subMenuRect.width / 2; + const targetY = subMenuRect.top + subMenuRect.height / 2; + const midX = (startX + targetX) / 2; + const midY = (startY + targetY) / 2; + pointerMove(midX, midY); + + await aTimeout(20); + + // Hover a sibling item — deferred by safe triangle + const siblingItem = getMenuItems(rootMenu)[3]; + activateItem(siblingItem); + + // Submenu should still be open (safe triangle active) + expect(subMenu.opened).to.be.true; + expect(getMenuItems(subMenu)[0].textContent).to.equal('foo-0-0'); + + // Wait for fallback timeout (400ms) to expire + await aTimeout(450); + + // The pending switch should have executed + expect(getMenuItems(subMenu)[0].textContent).to.equal('foo-3-0'); + }); + + it('should deactivate when submenu closes', async () => { + const safeTriangle = rootMenu.__safeTriangle; + expect(safeTriangle).to.exist; + + // Track pointer movement so shouldKeepOpen() would return true if still active + const parentRect = getMenuItems(rootMenu)[0].getBoundingClientRect(); + const subMenuRect = subMenu._overlayElement.getBoundingClientRect(); + pointerMove(parentRect.left + parentRect.width / 2, parentRect.top + parentRect.height / 2); + await aTimeout(20); + pointerMove(subMenuRect.left + subMenuRect.width / 2, subMenuRect.top + subMenuRect.height / 2); + await aTimeout(20); + + // Verify safe triangle is active before closing + expect(safeTriangle.shouldKeepOpen()).to.be.true; + + subMenu.close(); + await nextRender(); + expect(safeTriangle.shouldKeepOpen()).to.be.false; + }); + + it('should set safe-triangle-active attribute on list-box when active', () => { + const listBox = rootMenu._listBox; + expect(listBox.hasAttribute('safe-triangle-active')).to.be.true; + }); + + it('should remove safe-triangle-active attribute when deactivated', async () => { + const listBox = rootMenu._listBox; + expect(listBox.hasAttribute('safe-triangle-active')).to.be.true; + + subMenu.close(); + await nextRender(); + expect(listBox.hasAttribute('safe-triangle-active')).to.be.false; + }); + }); }); diff --git a/packages/menu-bar/src/vaadin-menu-bar-mixin.js b/packages/menu-bar/src/vaadin-menu-bar-mixin.js index 9f38f34a427..3306f2d30b1 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-mixin.js +++ b/packages/menu-bar/src/vaadin-menu-bar-mixin.js @@ -11,10 +11,12 @@ import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js'; import { isElementFocused, isElementHidden, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js'; import { microTask } from '@vaadin/component-base/src/async.js'; +import { isTouch } from '@vaadin/component-base/src/browser-utils.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js'; import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { SlotController } from '@vaadin/component-base/src/slot-controller.js'; +import { SafeTriangleController } from '@vaadin/context-menu/src/vaadin-safe-triangle-controller.js'; /** * Custom Lit directive for rendering item components @@ -283,6 +285,11 @@ export const MenuBarMixin = (superClass) => menu.addEventListener('item-selected', this.__onItemSelected.bind(this)); menu.addEventListener('close-all-menus', this.__onEscapeClose.bind(this)); + menu.addEventListener('opened-changed', (e) => { + if (!e.detail.value && this.__safeTriangle) { + this.__safeTriangle.deactivate(); + } + }); const overlay = menu._overlayElement; overlay._contentRoot.addEventListener('keydown', this.__boundOnContextMenuKeydown); @@ -314,6 +321,10 @@ export const MenuBarMixin = (superClass) => this.addEventListener('mousedown', () => this._hideTooltip(true)); this.addEventListener('mouseleave', () => this._hideTooltip()); + if (!isTouch) { + this.__safeTriangle = new SafeTriangleController(); + } + this._container = this.shadowRoot.querySelector('[part="container"]'); } @@ -907,7 +918,15 @@ export const MenuBarMixin = (superClass) => // with children, regardless of whether openOnHover is set. // If the button has no children, keep the sub-menu opened. if (button.item.children && (this.openOnHover || this._subMenu.opened)) { - this.__openSubMenu(button, false); + // 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.__openSubMenu(button, false); + }); + } else { + this.__openSubMenu(button, false); + } } if (button === this._overflow || (this.openOnHover && button.item.children)) { @@ -995,6 +1014,11 @@ export const MenuBarMixin = (superClass) => }), ); + // Activate safe triangle tracking for the newly opened submenu + if (this.__safeTriangle) { + this.__safeTriangle.activate(overlay, button, this); + } + overlay.addEventListener( 'vaadin-overlay-open', () => { diff --git a/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js b/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js index 6c3f9161ca6..c0b9b5fa85f 100644 --- a/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js +++ b/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js @@ -69,6 +69,7 @@ snapshots["menu-bar basic"] = snapshots["menu-bar opened"] = ` { }); }); }); + +(isTouch ? describe.skip : describe)('safe triangle', () => { + let menu, buttons, subMenu; + + beforeEach(async () => { + menu = fixtureSync(''); + menu.items = [ + { + text: 'Menu Item 1', + children: [{ text: 'Menu Item 1 1' }, { text: 'Menu Item 1 2', children: [{ text: 'Menu Item 1 2 1' }] }], + }, + { text: 'Menu Item 2' }, + { + text: 'Menu Item 3', + children: [{ text: 'Menu Item 3 1' }, { text: 'Menu Item 3 2' }], + }, + ]; + menu.openOnHover = true; + await nextRender(); + subMenu = menu._subMenu; + buttons = menu._buttons; + }); + + afterEach(() => { + document.documentElement.setAttribute('dir', 'ltr'); + }); + + it('should keep submenu open when pointer moves toward it', async () => { + // Open submenu for button 0 + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + const btnRect = buttons[0].getBoundingClientRect(); + const overlayRect = subMenu._overlayElement.getBoundingClientRect(); + + // Start from center of expanded button + const startX = btnRect.left + btnRect.width / 2; + const startY = btnRect.top + btnRect.height / 2; + pointerMove(startX, startY); + + await aTimeout(20); + + // Move pointer toward the submenu overlay (downward toward it) + const targetX = overlayRect.left + overlayRect.width / 2; + const targetY = overlayRect.top + overlayRect.height / 2; + const midX = (startX + targetX) / 2; + const midY = (startY + targetY) / 2; + pointerMove(midX, midY); + + await aTimeout(20); + + // Hover over another button — should NOT switch due to safe triangle + fire(buttons[2], 'mouseover'); + await nextRender(); + + expect(subMenu.opened).to.be.true; + expect(subMenu.listenOn).to.equal(buttons[0]); + }); + + it('should switch submenu when pointer moves away', async () => { + // Open submenu for button 0 + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + const btnRect = buttons[0].getBoundingClientRect(); + + // Start from center of expanded button + const startX = btnRect.left + btnRect.width / 2; + const startY = btnRect.top + btnRect.height / 2; + pointerMove(startX, startY); + + await aTimeout(20); + + // Move pointer upward (away from submenu which opens below) + pointerMove(startX, startY - 50); + + await aTimeout(20); + + // Move again to exceed threshold + pointerMove(startX, startY - 100); + + await aTimeout(20); + + // Hover over another button — should switch + fire(buttons[2], 'mouseover'); + await nextRender(); + + expect(subMenu.opened).to.be.true; + expect(subMenu.listenOn).to.equal(buttons[2]); + }); + + it('should deactivate safe triangle when submenu closes', async () => { + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + const safeTriangle = menu.__safeTriangle; + expect(safeTriangle).to.exist; + + // Track pointer movement so shouldKeepOpen() would return true if still active + const btnRect = buttons[0].getBoundingClientRect(); + const overlayRect = subMenu._overlayElement.getBoundingClientRect(); + pointerMove(btnRect.left + btnRect.width / 2, btnRect.top + btnRect.height / 2); + await aTimeout(20); + pointerMove(overlayRect.left + overlayRect.width / 2, overlayRect.top + overlayRect.height / 2); + await aTimeout(20); + + // Verify safe triangle is active before closing + expect(safeTriangle.shouldKeepOpen()).to.be.true; + + menu.close(); + await nextRender(); + + expect(safeTriangle.shouldKeepOpen()).to.be.false; + }); + + it('should not interfere when no pointer movement is tracked', async () => { + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + // Without any pointermove events, hovering another button should switch immediately + fire(buttons[2], 'mouseover'); + await nextRender(); + + expect(subMenu.opened).to.be.true; + expect(subMenu.listenOn).to.equal(buttons[2]); + }); + + it('should switch submenu after fallback timeout when pointer stops moving', async () => { + // Open submenu for button 0 + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + const btnRect = buttons[0].getBoundingClientRect(); + const overlayRect = subMenu._overlayElement.getBoundingClientRect(); + + // Start from center of expanded button + const startX = btnRect.left + btnRect.width / 2; + const startY = btnRect.top + btnRect.height / 2; + pointerMove(startX, startY); + + await aTimeout(20); + + // Move pointer toward the submenu overlay (inside safe triangle) + const targetX = overlayRect.left + overlayRect.width / 2; + const targetY = overlayRect.top + overlayRect.height / 2; + const midX = (startX + targetX) / 2; + const midY = (startY + targetY) / 2; + pointerMove(midX, midY); + + await aTimeout(20); + + // Hover over another button — deferred by safe triangle + fire(buttons[2], 'mouseover'); + await nextRender(); + + expect(subMenu.opened).to.be.true; + expect(subMenu.listenOn).to.equal(buttons[0]); + + // Wait for fallback timeout (400ms) to expire + await aTimeout(450); + + // The pending switch should have executed + expect(subMenu.listenOn).to.equal(buttons[2]); + }); + + it('should keep nested submenu open when pointer moves toward it in RTL', async () => { + document.documentElement.setAttribute('dir', 'rtl'); + menu.style.position = 'absolute'; + menu.style.left = '0px'; + await nextRender(); + + // Open first-level submenu via button hover + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + + const subMenuOverlay = subMenu._overlayElement; + const items = subMenuOverlay._contentRoot.querySelectorAll('vaadin-menu-bar-item'); + const parentItem = items[1]; // 'Menu Item 1 2' has children + + // Open nested submenu + const nestedSubMenu = subMenu.querySelector('vaadin-menu-bar-submenu'); + subMenu.__openListenerActive = true; + fire(parentItem, 'mouseover'); + await oneEvent(nestedSubMenu._overlayElement, 'vaadin-overlay-open'); + expect(nestedSubMenu.opened).to.be.true; + + const parentRect = parentItem.getBoundingClientRect(); + const nestedOverlayRect = nestedSubMenu._overlayElement.getBoundingClientRect(); + + // In RTL, nested submenu opens to the left + expect(nestedOverlayRect.right).to.be.at.most(parentRect.left + 1); + + // Start from center of parent item + const startX = parentRect.left + parentRect.width / 2; + const startY = parentRect.top + parentRect.height / 2; + pointerMove(startX, startY); + + await aTimeout(20); + + // Move diagonally toward the nested submenu (leftward in RTL) + const targetX = nestedOverlayRect.left + nestedOverlayRect.width / 2; + const targetY = nestedOverlayRect.top + nestedOverlayRect.height / 2; + const midX = (startX + targetX) / 2; + const midY = (startY + targetY) / 2; + pointerMove(midX, midY); + + await aTimeout(20); + + // Hover a sibling item in the first-level submenu + const siblingItem = items[0]; // 'Menu Item 1 1' + fire(siblingItem, 'mouseover'); + + // Nested submenu should stay open (safe triangle protects it) + expect(nestedSubMenu.opened).to.be.true; + }); + + it('should set safe-triangle-active attribute on menu-bar when active', async () => { + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(subMenu.opened).to.be.true; + expect(menu.hasAttribute('safe-triangle-active')).to.be.true; + }); + + it('should remove safe-triangle-active attribute when deactivated', async () => { + fire(buttons[0], 'mouseover'); + await nextRender(); + expect(menu.hasAttribute('safe-triangle-active')).to.be.true; + + menu.close(); + await nextRender(); + expect(menu.hasAttribute('safe-triangle-active')).to.be.false; + }); +}); diff --git a/packages/vaadin-lumo-styles/src/components/context-menu-list-box.css b/packages/vaadin-lumo-styles/src/components/context-menu-list-box.css index 2a105e6f22d..ae44fb1e25d 100644 --- a/packages/vaadin-lumo-styles/src/components/context-menu-list-box.css +++ b/packages/vaadin-lumo-styles/src/components/context-menu-list-box.css @@ -25,6 +25,11 @@ background-color: var(--lumo-primary-color-10pct); } + /* Suppress hover highlight during safe triangle navigation */ + :host([safe-triangle-active]) [part='items'] ::slotted([role='menuitem']:hover:not([disabled]):not([expanded])) { + background-color: transparent; + } + /* RTL styles */ :host([dir='rtl']) [part='items'] ::slotted([role='menuitem']) { padding-left: calc(var(--lumo-space-l) + var(--lumo-border-radius-m) / 4);