@@ -117,6 +117,28 @@ class PuterContextMenu extends PuterWebComponent {
117117 filter: brightness(0) invert(1);
118118 }
119119
120+ /* Safe-triangle: while the cursor traces a diagonal path toward
121+ an open submenu, suppress :hover highlight on intermediate
122+ items so they don't flash blue. .focused and .has-open-submenu
123+ (managed by JS) still highlight normally. */
124+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
125+ background-color: transparent;
126+ color: #333;
127+ }
128+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
129+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
130+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
131+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
132+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
133+ color: #333;
134+ }
135+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
136+ filter: none;
137+ }
138+ .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
139+ filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51));
140+ }
141+
120142 /* .has-open-context-menu-submenu — line 1738-1739 */
121143 .menu-item.has-open-submenu:not(:hover) {
122144 background-color: #dfdfdf;
@@ -213,6 +235,8 @@ class PuterContextMenu extends PuterWebComponent {
213235 margin-left: 16px;
214236 font-size: 11px;
215237 color: #999;
238+ flex-shrink: 0;
239+ letter-spacing: 0.5px;
216240 }
217241
218242 /* === iOS-style action sheet (mobile) ========================= */
@@ -294,7 +318,7 @@ class PuterContextMenu extends PuterWebComponent {
294318
295319 :host(.sheet-mode) .icon {
296320 width: 24px;
297- margin-right: 12px ;
321+ margin-right: 0px ;
298322 }
299323 :host(.sheet-mode) .icon svg,
300324 :host(.sheet-mode) .icon img {
@@ -342,7 +366,7 @@ class PuterContextMenu extends PuterWebComponent {
342366 : '' ;
343367
344368 const shortcutHTML = item . shortcut
345- ? `<span class="shortcut">${ this . _escapeHTML ( item . shortcut ) } </span>`
369+ ? `<span class="shortcut">${ this . _escapeHTML ( this . _formatShortcut ( item . shortcut ) ) } </span>`
346370 : '' ;
347371
348372 return `
@@ -431,7 +455,7 @@ class PuterContextMenu extends PuterWebComponent {
431455 _bindEvents ( ) {
432456 // Remove any stale document listeners from a prior render
433457 if ( this . _outsideClickHandler ) {
434- document . removeEventListener ( 'click ' , this . _outsideClickHandler , true ) ;
458+ document . removeEventListener ( 'pointerdown ' , this . _outsideClickHandler , true ) ;
435459 }
436460 if ( this . _keyHandler ) {
437461 document . removeEventListener ( 'keydown' , this . _keyHandler , true ) ;
@@ -472,6 +496,7 @@ class PuterContextMenu extends PuterWebComponent {
472496 el . addEventListener ( 'mouseenter' , ( ) => {
473497 if ( el . dataset . hasSubmenu === 'true' ) {
474498 this . #pendingFocusIndex = null ;
499+ this . _setSafeTraverse ( false ) ;
475500 this . _setFocusIndex ( index ) ;
476501 this . _cancelSubmenuClose ( ) ;
477502 clearTimeout ( this . #submenuTimeout) ;
@@ -485,16 +510,25 @@ class PuterContextMenu extends PuterWebComponent {
485510 }
486511 } else if ( this . #activeSubmenu ) {
487512 // Safe-triangle: if cursor is heading toward the submenu,
488- // defer focus change so intermediate items don't highlight
513+ // defer focus change AND suppress :hover styling on this
514+ // item so it doesn't flash blue mid-traversal.
489515 if ( this . _isMouseHeadingToSubmenu ( this . #activeSubmenu. element ) ) {
490516 this . #pendingFocusIndex = index ;
491- this . _cancelSubmenuClose ( ) ;
517+ this . _setSafeTraverse ( true ) ;
518+ // Don't call _cancelSubmenuClose — it clears
519+ // pendingFocusIndex. Just clear the close timer.
520+ if ( this . #submenuCloseTimer ) {
521+ clearTimeout ( this . #submenuCloseTimer) ;
522+ this . #submenuCloseTimer = null ;
523+ }
492524 this . #submenuCloseTimer = setTimeout ( ( ) => this . _submenuCloseCheck ( ) , 100 ) ;
493525 return ;
494526 }
527+ this . _setSafeTraverse ( false ) ;
495528 this . _setFocusIndex ( index ) ;
496529 this . _scheduleSubmenuClose ( ) ;
497530 } else {
531+ this . _setSafeTraverse ( false ) ;
498532 this . _setFocusIndex ( index ) ;
499533 }
500534 } ) ;
@@ -507,14 +541,18 @@ class PuterContextMenu extends PuterWebComponent {
507541 } ) ;
508542 } ) ;
509543
510- // Close on outside click
544+ // Close on outside pointerdown — fires the instant the press starts,
545+ // before mouseup/click, so the menu doesn't linger during a drag.
546+ // Submenus are sibling elements appended to <body>, so we explicitly
547+ // walk the submenu chain — a click in a descendant submenu must not
548+ // tear us (and therefore that submenu) down.
511549 this . _outsideClickHandler = ( e ) => {
512- if ( ! this . contains ( e . target ) ) {
550+ if ( ! this . _isEventInChain ( e ) ) {
513551 this . _closeAll ( ) ;
514552 }
515553 } ;
516554 setTimeout ( ( ) => {
517- document . addEventListener ( 'click ' , this . _outsideClickHandler , true ) ;
555+ document . addEventListener ( 'pointerdown ' , this . _outsideClickHandler , true ) ;
518556 } , 0 ) ;
519557
520558 // Track mouse for safe-triangle submenu hover
@@ -809,20 +847,26 @@ class PuterContextMenu extends PuterWebComponent {
809847 clearTimeout ( this . #submenuCloseTimer) ;
810848 this . #submenuCloseTimer = null ;
811849 }
812- // User reached the submenu — discard deferred focus
850+ // User reached the submenu — discard deferred focus and end traversal
813851 this . #pendingFocusIndex = null ;
852+ this . _setSafeTraverse ( false ) ;
814853 }
815854
816855 _submenuCloseCheck ( ) {
817856 this . #submenuCloseTimer = null ;
818- if ( ! this . #activeSubmenu ) return ;
857+ if ( ! this . #activeSubmenu ) {
858+ this . _setSafeTraverse ( false ) ;
859+ return ;
860+ }
819861
820862 // If cursor is currently over the submenu or the parent item, keep open
821863 const submenu = this . #activeSubmenu. element ;
822864 const parentEl = this . #activeSubmenu. parentEl ;
823865 const latest = this . #mouseLocs[ this . #mouseLocs. length - 1 ] ;
824866 if ( latest ) {
825867 if ( this . _pointInElement ( latest , submenu ) || this . _pointInRect ( latest , parentEl . getBoundingClientRect ( ) ) ) {
868+ // Cursor arrived at submenu / parent — end safe-triangle mode
869+ this . _setSafeTraverse ( false ) ;
826870 return ;
827871 }
828872 }
@@ -834,9 +878,16 @@ class PuterContextMenu extends PuterWebComponent {
834878 return ;
835879 }
836880
881+ // Trajectory no longer heading to submenu — end traversal mode
882+ this . _setSafeTraverse ( false ) ;
837883 this . _hideActiveSubmenu ( ) ;
838884 }
839885
886+ _setSafeTraverse ( on ) {
887+ const menu = this . $ ( '.context-menu' ) ;
888+ if ( menu ) menu . classList . toggle ( 'safe-traverse' , on ) ;
889+ }
890+
840891 _pointInRect ( p , r ) {
841892 return p . x >= r . left && p . x <= r . right && p . y >= r . top && p . y <= r . bottom ;
842893 }
@@ -911,6 +962,7 @@ class PuterContextMenu extends PuterWebComponent {
911962 this . _setFocusIndex ( this . #pendingFocusIndex) ;
912963 this . #pendingFocusIndex = null ;
913964 }
965+ this . _setSafeTraverse ( false ) ;
914966 }
915967
916968 _closeAll ( ) {
@@ -924,7 +976,7 @@ class PuterContextMenu extends PuterWebComponent {
924976 const wasHidden = this . _sheetHidden ;
925977 this . _hideActiveSubmenu ( false ) ;
926978 if ( this . _outsideClickHandler ) {
927- document . removeEventListener ( 'click ' , this . _outsideClickHandler , true ) ;
979+ document . removeEventListener ( 'pointerdown ' , this . _outsideClickHandler , true ) ;
928980 }
929981 if ( this . _keyHandler ) {
930982 document . removeEventListener ( 'keydown' , this . _keyHandler , true ) ;
@@ -953,7 +1005,7 @@ class PuterContextMenu extends PuterWebComponent {
9531005
9541006 disconnectedCallback ( ) {
9551007 if ( this . _outsideClickHandler ) {
956- document . removeEventListener ( 'click ' , this . _outsideClickHandler , true ) ;
1008+ document . removeEventListener ( 'pointerdown ' , this . _outsideClickHandler , true ) ;
9571009 }
9581010 if ( this . _keyHandler ) {
9591011 document . removeEventListener ( 'keydown' , this . _keyHandler , true ) ;
@@ -980,6 +1032,89 @@ class PuterContextMenu extends PuterWebComponent {
9801032 if ( ! str ) return '' ;
9811033 return str . replace ( / " / g, '"' ) . replace ( / ' / g, ''' ) ;
9821034 }
1035+
1036+ /**
1037+ * Render a keyboard-shortcut string in OS-appropriate form.
1038+ *
1039+ * Mac: modifiers as glyphs, concatenated → ⇧⌘D
1040+ * Win/Linux: text labels joined with '+' → Ctrl+Shift+D
1041+ *
1042+ * Accepts portable tokens (Mod, Cmd, Ctrl, Alt, Option, Shift, Meta) and
1043+ * the literal Mac glyphs (⌘ ⌃ ⌥ ⇧). 'Mod' is the recommended portable
1044+ * name — it maps to Cmd on Mac and Ctrl elsewhere.
1045+ */
1046+ _formatShortcut ( str ) {
1047+ if ( ! str ) return '' ;
1048+ const isMac = PuterContextMenu . _isMac ( ) ;
1049+
1050+ // Inflate any glyphs into named tokens so we can re-emit per OS.
1051+ const normalized = String ( str )
1052+ . replace ( / ⌘ / g, 'Mod+' ) // ⌘
1053+ . replace ( / ⌃ / g, 'Ctrl+' ) // ⌃
1054+ . replace ( / ⌥ / g, 'Alt+' ) // ⌥
1055+ . replace ( / ⇧ / g, 'Shift+' ) ; // ⇧
1056+
1057+ const tokens = normalized . split ( '+' ) . map ( t => t . trim ( ) ) . filter ( Boolean ) ;
1058+
1059+ const out = tokens . map ( t => {
1060+ switch ( t . toLowerCase ( ) ) {
1061+ case 'mod' :
1062+ case 'cmd' :
1063+ case 'command' :
1064+ return isMac ? '⌘' : 'Ctrl' ;
1065+ case 'ctrl' :
1066+ case 'control' :
1067+ return isMac ? '⌃' : 'Ctrl' ;
1068+ case 'alt' :
1069+ case 'option' :
1070+ case 'opt' :
1071+ return isMac ? '⌥' : 'Alt' ;
1072+ case 'shift' :
1073+ return isMac ? '⇧' : 'Shift' ;
1074+ case 'meta' :
1075+ case 'super' :
1076+ case 'win' :
1077+ return isMac ? '⌘' : 'Win' ;
1078+ default :
1079+ return t ;
1080+ }
1081+ } ) ;
1082+
1083+ return isMac ? out . join ( '' ) : out . join ( '+' ) ;
1084+ }
1085+
1086+ _getActiveSubmenu ( ) {
1087+ return this . #activeSubmenu;
1088+ }
1089+
1090+ /**
1091+ * Returns true if a document-level event targets this menu or any
1092+ * submenu nested below it. e.target on shadow-DOM-crossing events is
1093+ * the submenu's host element, so we use contains() on each host in the
1094+ * chain.
1095+ */
1096+ _isEventInChain ( e ) {
1097+ const target = e . target ;
1098+ if ( ! target ) return false ;
1099+ if ( this . contains ( target ) ) return true ;
1100+ let cur = this . #activeSubmenu;
1101+ while ( cur && cur . element ) {
1102+ if ( cur . element . contains ( target ) ) return true ;
1103+ cur = cur . element . _getActiveSubmenu
1104+ ? cur . element . _getActiveSubmenu ( )
1105+ : null ;
1106+ }
1107+ return false ;
1108+ }
1109+
1110+ static _isMac ( ) {
1111+ if ( typeof navigator === 'undefined' ) return false ;
1112+ const uaData = navigator . userAgentData ;
1113+ if ( uaData && typeof uaData . platform === 'string' ) {
1114+ return / m a c / i. test ( uaData . platform ) ;
1115+ }
1116+ return / M a c | i P h o n e | i P a d | i P o d / i. test ( navigator . platform || navigator . userAgent || '' ) ;
1117+ }
9831118}
9841119
9851120export default PuterContextMenu ;
0 commit comments