diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 59488fb5d..993f2185f 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -117,6 +117,28 @@ class PuterContextMenu extends PuterWebComponent { filter: brightness(0) invert(1); } + /* Safe-triangle: while the cursor traces a diagonal path toward + an open submenu, suppress :hover highlight on intermediate + items so they don't flash blue. .focused and .has-open-submenu + (managed by JS) still highlight normally. */ + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) { + background-color: transparent; + color: #333; + } + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon, + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check, + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow, + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut, + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label { + color: #333; + } + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg { + filter: none; + } + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img { + filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51)); + } + /* .has-open-context-menu-submenu — line 1738-1739 */ .menu-item.has-open-submenu:not(:hover) { background-color: #dfdfdf; @@ -213,6 +235,8 @@ class PuterContextMenu extends PuterWebComponent { margin-left: 16px; font-size: 11px; color: #999; + flex-shrink: 0; + letter-spacing: 0.5px; } /* === iOS-style action sheet (mobile) ========================= */ @@ -294,7 +318,7 @@ class PuterContextMenu extends PuterWebComponent { :host(.sheet-mode) .icon { width: 24px; - margin-right: 12px; + margin-right: 0px; } :host(.sheet-mode) .icon svg, :host(.sheet-mode) .icon img { @@ -342,7 +366,7 @@ class PuterContextMenu extends PuterWebComponent { : ''; const shortcutHTML = item.shortcut - ? `${this._escapeHTML(item.shortcut)}` + ? `${this._escapeHTML(this._formatShortcut(item.shortcut))}` : ''; return ` @@ -431,7 +455,7 @@ class PuterContextMenu extends PuterWebComponent { _bindEvents () { // Remove any stale document listeners from a prior render if ( this._outsideClickHandler ) { - document.removeEventListener('click', this._outsideClickHandler, true); + document.removeEventListener('pointerdown', this._outsideClickHandler, true); } if ( this._keyHandler ) { document.removeEventListener('keydown', this._keyHandler, true); @@ -472,6 +496,7 @@ class PuterContextMenu extends PuterWebComponent { el.addEventListener('mouseenter', () => { if ( el.dataset.hasSubmenu === 'true' ) { this.#pendingFocusIndex = null; + this._setSafeTraverse(false); this._setFocusIndex(index); this._cancelSubmenuClose(); clearTimeout(this.#submenuTimeout); @@ -485,16 +510,25 @@ class PuterContextMenu extends PuterWebComponent { } } else if ( this.#activeSubmenu ) { // Safe-triangle: if cursor is heading toward the submenu, - // defer focus change so intermediate items don't highlight + // defer focus change AND suppress :hover styling on this + // item so it doesn't flash blue mid-traversal. if ( this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) { this.#pendingFocusIndex = index; - this._cancelSubmenuClose(); + this._setSafeTraverse(true); + // Don't call _cancelSubmenuClose — it clears + // pendingFocusIndex. Just clear the close timer. + if ( this.#submenuCloseTimer ) { + clearTimeout(this.#submenuCloseTimer); + this.#submenuCloseTimer = null; + } this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100); return; } + this._setSafeTraverse(false); this._setFocusIndex(index); this._scheduleSubmenuClose(); } else { + this._setSafeTraverse(false); this._setFocusIndex(index); } }); @@ -507,14 +541,18 @@ class PuterContextMenu extends PuterWebComponent { }); }); - // Close on outside click + // Close on outside pointerdown — fires the instant the press starts, + // before mouseup/click, so the menu doesn't linger during a drag. + // Submenus are sibling elements appended to , so we explicitly + // walk the submenu chain — a click in a descendant submenu must not + // tear us (and therefore that submenu) down. this._outsideClickHandler = (e) => { - if ( ! this.contains(e.target) ) { + if ( ! this._isEventInChain(e) ) { this._closeAll(); } }; setTimeout(() => { - document.addEventListener('click', this._outsideClickHandler, true); + document.addEventListener('pointerdown', this._outsideClickHandler, true); }, 0); // Track mouse for safe-triangle submenu hover @@ -809,13 +847,17 @@ class PuterContextMenu extends PuterWebComponent { clearTimeout(this.#submenuCloseTimer); this.#submenuCloseTimer = null; } - // User reached the submenu — discard deferred focus + // User reached the submenu — discard deferred focus and end traversal this.#pendingFocusIndex = null; + this._setSafeTraverse(false); } _submenuCloseCheck () { this.#submenuCloseTimer = null; - if ( ! this.#activeSubmenu ) return; + if ( ! this.#activeSubmenu ) { + this._setSafeTraverse(false); + return; + } // If cursor is currently over the submenu or the parent item, keep open const submenu = this.#activeSubmenu.element; @@ -823,6 +865,8 @@ class PuterContextMenu extends PuterWebComponent { const latest = this.#mouseLocs[this.#mouseLocs.length - 1]; if ( latest ) { if ( this._pointInElement(latest, submenu) || this._pointInRect(latest, parentEl.getBoundingClientRect()) ) { + // Cursor arrived at submenu / parent — end safe-triangle mode + this._setSafeTraverse(false); return; } } @@ -834,9 +878,16 @@ class PuterContextMenu extends PuterWebComponent { return; } + // Trajectory no longer heading to submenu — end traversal mode + this._setSafeTraverse(false); this._hideActiveSubmenu(); } + _setSafeTraverse (on) { + const menu = this.$('.context-menu'); + if ( menu ) menu.classList.toggle('safe-traverse', on); + } + _pointInRect (p, r) { return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; } @@ -911,6 +962,7 @@ class PuterContextMenu extends PuterWebComponent { this._setFocusIndex(this.#pendingFocusIndex); this.#pendingFocusIndex = null; } + this._setSafeTraverse(false); } _closeAll () { @@ -924,7 +976,7 @@ class PuterContextMenu extends PuterWebComponent { const wasHidden = this._sheetHidden; this._hideActiveSubmenu(false); if ( this._outsideClickHandler ) { - document.removeEventListener('click', this._outsideClickHandler, true); + document.removeEventListener('pointerdown', this._outsideClickHandler, true); } if ( this._keyHandler ) { document.removeEventListener('keydown', this._keyHandler, true); @@ -953,7 +1005,7 @@ class PuterContextMenu extends PuterWebComponent { disconnectedCallback () { if ( this._outsideClickHandler ) { - document.removeEventListener('click', this._outsideClickHandler, true); + document.removeEventListener('pointerdown', this._outsideClickHandler, true); } if ( this._keyHandler ) { document.removeEventListener('keydown', this._keyHandler, true); @@ -980,6 +1032,89 @@ class PuterContextMenu extends PuterWebComponent { if ( ! str ) return ''; return str.replace(/"/g, '"').replace(/'/g, '''); } + + /** + * Render a keyboard-shortcut string in OS-appropriate form. + * + * Mac: modifiers as glyphs, concatenated → ⇧⌘D + * Win/Linux: text labels joined with '+' → Ctrl+Shift+D + * + * Accepts portable tokens (Mod, Cmd, Ctrl, Alt, Option, Shift, Meta) and + * the literal Mac glyphs (⌘ ⌃ ⌥ ⇧). 'Mod' is the recommended portable + * name — it maps to Cmd on Mac and Ctrl elsewhere. + */ + _formatShortcut (str) { + if ( ! str ) return ''; + const isMac = PuterContextMenu._isMac(); + + // Inflate any glyphs into named tokens so we can re-emit per OS. + const normalized = String(str) + .replace(/⌘/g, 'Mod+') // ⌘ + .replace(/⌃/g, 'Ctrl+') // ⌃ + .replace(/⌥/g, 'Alt+') // ⌥ + .replace(/⇧/g, 'Shift+'); // ⇧ + + const tokens = normalized.split('+').map(t => t.trim()).filter(Boolean); + + const out = tokens.map(t => { + switch ( t.toLowerCase() ) { + case 'mod': + case 'cmd': + case 'command': + return isMac ? '⌘' : 'Ctrl'; + case 'ctrl': + case 'control': + return isMac ? '⌃' : 'Ctrl'; + case 'alt': + case 'option': + case 'opt': + return isMac ? '⌥' : 'Alt'; + case 'shift': + return isMac ? '⇧' : 'Shift'; + case 'meta': + case 'super': + case 'win': + return isMac ? '⌘' : 'Win'; + default: + return t; + } + }); + + return isMac ? out.join('') : out.join('+'); + } + + _getActiveSubmenu () { + return this.#activeSubmenu; + } + + /** + * Returns true if a document-level event targets this menu or any + * submenu nested below it. e.target on shadow-DOM-crossing events is + * the submenu's host element, so we use contains() on each host in the + * chain. + */ + _isEventInChain (e) { + const target = e.target; + if ( ! target ) return false; + if ( this.contains(target) ) return true; + let cur = this.#activeSubmenu; + while ( cur && cur.element ) { + if ( cur.element.contains(target) ) return true; + cur = cur.element._getActiveSubmenu + ? cur.element._getActiveSubmenu() + : null; + } + return false; + } + + static _isMac () { + if ( typeof navigator === 'undefined' ) return false; + const uaData = navigator.userAgentData; + if ( uaData && typeof uaData.platform === 'string' ) { + return /mac/i.test(uaData.platform); + } + return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || ''); + } } export default PuterContextMenu; diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index ffda68ad6..bb6843acd 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -114,6 +114,14 @@ class PuterMenubar extends PuterWebComponent { btn.addEventListener('click', (e) => { e.stopPropagation(); + // The dropdown closes on outside pointerdown, which fires + // before this click. If the user is pressing the same button + // that just closed, treat the press as a toggle-close — + // don't reopen on the trailing click. + if ( this._suppressClickFor === btn ) { + this._suppressClickFor = null; + return; + } this.#focusedIndex = index; this.#menubarActive = true; if ( this.#activeButtonEl === btn ) { @@ -124,6 +132,19 @@ class PuterMenubar extends PuterWebComponent { this._openDropdown(btn, item); }); + // pointerdown on this button while it owns the open dropdown: + // mark it so the outside-pointerdown close (about to fire) and + // the trailing click don't reopen. + btn.addEventListener('pointerdown', () => { + if ( this.#activeButtonEl === btn ) { + this._suppressClickFor = btn; + clearTimeout(this._suppressClickTimer); + this._suppressClickTimer = setTimeout(() => { + this._suppressClickFor = null; + }, 400); + } + }); + // Hover-switch when a dropdown is already open btn.addEventListener('mouseenter', () => { if ( this.#activeDropdown && this.#activeButtonEl !== btn ) { @@ -290,11 +311,14 @@ class PuterMenubar extends PuterWebComponent { this._deactivateMenubar(); }); dropdown.addEventListener('close', () => { - // The context menu closes itself on outside click; sync our state + // The context menu closes itself on outside click / Escape / + // selection. Sync our state and fully deactivate the menubar so + // a stray arrow / Enter / Space keypress doesn't re-open it. if ( this.#activeDropdown === dropdown ) { buttonEl.classList.remove('active'); this.#activeDropdown = null; this.#activeButtonEl = null; + this._deactivateMenubar(); } }); // Keyboard navigate request bubbling from the context menu @@ -325,6 +349,8 @@ class PuterMenubar extends PuterWebComponent { disconnectedCallback () { this._closeDropdown(); + clearTimeout(this._suppressClickTimer); + this._suppressClickFor = null; if ( this._keyHandler ) { document.removeEventListener('keydown', this._keyHandler, true); }