Skip to content

Comments

refactor: implement safe triangle pattern for submenu navigation#11183

Draft
DiegoCardoso wants to merge 8 commits intomainfrom
refactor/context-menu/safe-triangle
Draft

refactor: implement safe triangle pattern for submenu navigation#11183
DiegoCardoso wants to merge 8 commits intomainfrom
refactor/context-menu/safe-triangle

Conversation

@DiegoCardoso
Copy link
Contributor

Summary

Fixes #9904

  • Introduces a SafeTriangleController that prevents premature submenu closing when the user moves the mouse diagonally from a parent menu item toward the open submenu
  • Integrates the controller into both vaadin-context-menu (items mixin) and vaadin-menu-bar (menu bar mixin)
  • Uses atan2 angle comparison (inspired by React Aria's approach) to detect whether the cursor is aimed at the submenu, with a 15° angular tolerance and a 2-strike threshold before allowing submenu switches

How it works

When a submenu is open, a pointermove listener on document tracks cursor movement (throttled to 16ms). For each movement, it computes angles from the cursor to the near-edge corners of the submenu overlay. If the cursor's movement vector falls within that angular cone (plus tolerance), the submenu stays open. Only after 2 consecutive "miss" movements does the controller allow the switch.

Key design decisions:

  • Lazy activation — listener only attached while a submenu is open, zero overhead otherwise
  • Touch-safe — skipped entirely for pointerType === 'touch' or 'pen'
  • Direction-agnostic — detects submenu direction from actual overlay position at runtime (handles both LTR/RTL and viewport-edge flipping)
  • Accessible — the 2-strike threshold accommodates motor impairments and hand tremors

Performance analysis

Chrome DevTools performance traces were captured before (commit b0d3dd98c8) and after (commit 76db29d76b) the change, using the same dev/menu-bar.html page and identical interaction sequences (open Share submenu → hover nested "On social media" → hover sibling "Get link" → repeat).

Page load (control — controller inactive)

Metric Before After Delta
LCP 745 ms 740 ms -5 ms (noise)
CLS 0.00 0.00 0

Interaction (hot path — controller active during submenu hover)

Metric Before After Delta
INP 40 ms 46 ms +6 ms (noise)
Long tasks (>50ms) 0 0 0

Both INP values are well within the "Good" threshold (< 200ms). The +6ms delta is evenly distributed across all INP phases (input delay, processing, presentation), consistent with measurement noise rather than a systematic regression. No long tasks were introduced in either run.

Conclusion: The SafeTriangleController introduces no measurable performance overhead.

Test plan

  • yarn test --group context-menu — 210 passed, 0 failed
  • yarn test --group menu-bar — 180 passed, 0 failed
  • Manual testing at /dev/menu-bar.html and /dev/context-menu.html with diagonal mouse movements
  • Verify keyboard navigation is unaffected
  • Verify touch behavior is unaffected

🤖 Generated with Claude Code

DiegoCardoso and others added 8 commits February 20, 2026 18:21
Add SafeTriangleController that uses atan2 angle comparison to detect
when the cursor is aimed at an open submenu, preventing premature
submenu switching during diagonal mouse movement. Integrate into both
context-menu items mixin and menu-bar mixin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…obustness

- Reset _lastX/_lastY/_hasLastPosition on activate() to prevent stale
  position data from previous submenu sessions
- Replace fragile (0,0) coordinate sentinel with _hasLastPosition boolean
  flag to handle cursor at viewport origin correctly
- Add 400ms fallback timeout in scheduleSwitch() so pending submenu
  switches fire even when the user stops moving the mouse
- Declare _pendingSwitch in constructor for consistency
- Remove redundant !isTouch guard in context-menu items mixin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace underscore-prefixed conventions with native # private fields
and methods for true language-enforced encapsulation. Convert the bound
pointer move handler to an arrow function field, eliminating .bind().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract item reference eagerly before the safe triangle check since
composedPath() returns an empty array in async callbacks. Replace
if/return/fallthrough with if/else and remove redundant inner guard
that __showSubMenu already handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract duplicated pointerMove helper to context-menu test helpers
and replace inline setTimeout with aTimeout from testing-helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cancel pending switch in activate() to prevent stale callbacks during
rapid hover. Clear timeout in executePendingSwitch() when called from
pointermove handler. Add fallback timeout and RTL safe triangle tests
for both context-menu and menu-bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove keyboard navigation test from safe triangle block (already
covered elsewhere, never exercised safe triangle code). Fix both
deactivation tests to track pointer movement before closing, so
shouldKeepOpen() would return true if deactivate() were not called.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sonarqubecloud
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[menu-bar] keep sub-menu open when moving diagonally over other menu items with the mouse pointer

1 participant