diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 3e4446736..3592c1422 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -580,14 +580,15 @@ class PuterContextMenu extends PuterWebComponent { this.#mouseTracker = null; } - // Hovering a separator should drop the focus highlight from the - // previously-focused item — the cursor has clearly moved past it. - // It should also close any submenu opened from an item above, so - // the parent's .has-open-submenu highlight goes away too. + // Hovering a separator or disabled item should drop the focus + // highlight from the previously-focused item — the cursor has + // clearly moved past it. It should also close any submenu opened + // from an item above, so the parent's .has-open-submenu highlight + // goes away too. // Exception: if the cursor is on a diagonal path toward the open - // submenu, treat the divider like any other safe-traverse cell so - // we don't tear down the submenu mid-drag. - this.$$('.menu-item.divider').forEach((el) => { + // submenu, treat these like any other safe-traverse cell so we + // don't tear down the submenu mid-drag. + this.$$('.menu-item.divider, .menu-item.disabled').forEach((el) => { el.addEventListener('mouseenter', () => { if ( this.#activeSubmenu && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) { this._setSafeTraverse(true); @@ -636,6 +637,23 @@ class PuterContextMenu extends PuterWebComponent { // Hover: mouse takes over focus highlight el.addEventListener('mouseenter', () => { if ( el.dataset.hasSubmenu === 'true' ) { + // Safe-triangle: if a *different* submenu is already + // open and the cursor is tracing diagonally toward it, + // defer the swap. Without this, two adjacent items + // with submenus would close the target as soon as the + // cursor passed over the second one. + if ( this.#activeSubmenu + && this.#activeSubmenu.parentEl !== el + && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) { + this.#pendingFocusIndex = index; + this._setSafeTraverse(true); + if ( this.#submenuCloseTimer ) { + clearTimeout(this.#submenuCloseTimer); + this.#submenuCloseTimer = null; + } + this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100); + return; + } this.#pendingFocusIndex = null; this._setSafeTraverse(false); this._setFocusIndex(index); @@ -682,6 +700,17 @@ class PuterContextMenu extends PuterWebComponent { }); }); + // When the cursor leaves the menu entirely, drop the item highlight + // so the user isn't left with a stale focus mark while the cursor + // is elsewhere. Submenu parents use .has-open-submenu (not .focused), + // so this is safe even while a submenu is open. + const menuRoot = this.$('.context-menu'); + if ( menuRoot ) { + menuRoot.addEventListener('mouseleave', () => { + this._clearFocus(); + }); + } + // 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 @@ -1141,10 +1170,19 @@ class PuterContextMenu extends PuterWebComponent { this.style.display = ''; this._sheetHidden = false; } - // Apply deferred focus from safe-triangle hover + // Apply deferred focus from safe-triangle hover. If the deferred + // item is itself a submenu opener, open it now — the user crossed + // the safe triangle without reaching the previous submenu, so we + // commit to the new target. if ( this.#pendingFocusIndex !== null ) { - this._setFocusIndex(this.#pendingFocusIndex); + const idx = this.#pendingFocusIndex; this.#pendingFocusIndex = null; + this._setFocusIndex(idx); + const pending = this.#items[idx]; + if ( pending && pending.items && pending.items.length ) { + const pendingEl = this._itemEl(idx); + if ( pendingEl ) this._showSubmenu(pendingEl, pending.items); + } } this._setSafeTraverse(false); } diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index 0104d678a..4d2c1688e 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -63,17 +63,25 @@ class PuterMenubar extends PuterWebComponent { line-height: 1.2; margin: 0 1px; } - .menu-button:hover, + /* Hover is driven by JS (.hovered) rather than :hover so we can + clear stale keyboard focus the moment the mouse takes over and + keep the two highlight sources from ever co-existing. */ + .menu-button.hovered, .menu-button.active, .menu-button.focused { background-color: #e2e2e2; } - /* Suppress stale :hover during keyboard nav so only the focused - button highlights. Cleared on the next mousemove. */ - .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { - background-color: transparent; + /* Suppress browser-native focus ring and tap highlight without + touching background. CAUTION: setting background-color here + would tie specificity with .menu-button.active and win by + source order, which would erase the open-menu highlight as + soon as the button takes DOM :focus from a click. */ + .menu-button:focus, + .menu-button:focus-visible, + .menu-button:active { + outline: none; + -webkit-tap-highlight-color: transparent; } - .menu-button:focus { outline: none; } @media (max-width: 480px) { .menubar { height: 40px; @@ -101,14 +109,11 @@ class PuterMenubar extends PuterWebComponent { :host(.puter-theme-dark) .menu-button { color: #e6e6e6; } - :host(.puter-theme-dark) .menu-button:hover, + :host(.puter-theme-dark) .menu-button.hovered, :host(.puter-theme-dark) .menu-button.active, :host(.puter-theme-dark) .menu-button.focused { background-color: #3a3a3a; } - :host(.puter-theme-dark) .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { - background-color: transparent; - } `; } @@ -131,6 +136,12 @@ class PuterMenubar extends PuterWebComponent { if ( this._docPointerDownHandler ) { document.removeEventListener('pointerdown', this._docPointerDownHandler, true); } + if ( this._docFocusInHandler ) { + document.removeEventListener('focusin', this._docFocusInHandler, true); + } + if ( this._winBlurHandler ) { + window.removeEventListener('blur', this._winBlurHandler); + } if ( this._mouseMoveHandler ) { document.removeEventListener('mousemove', this._mouseMoveHandler); } @@ -161,13 +172,31 @@ class PuterMenubar extends PuterWebComponent { this._openDropdown(btn, item); }); - // Hover-switch when a dropdown is already open + // JS-managed hover. Adding .hovered while clearing any keyboard + // .focused guarantees only one button highlights at a time, no + // matter the source. btn.addEventListener('mouseenter', () => { + btn.classList.add('hovered'); + // Mouse takes over: clear keyboard focus from everywhere + // and drop any lingering DOM :focus on a sibling button. + if ( this.#focusedIndex !== null ) { + this.#focusedIndex = null; + this._renderButtonFocus(); + } + const root = this.shadowRoot; + const active = root && root.activeElement; + if ( active && active !== btn && active.classList.contains('menu-button') ) { + active.blur(); + } + this._setKeyboardNav(false); + // Hover-switch when a dropdown is already open if ( this.#activeDropdown && this.#activeButtonEl !== btn ) { - this.#focusedIndex = index; this._openDropdown(btn, item); } }); + btn.addEventListener('mouseleave', () => { + btn.classList.remove('hovered'); + }); }); this._keyHandler = (e) => this._onGlobalKeyDown(e); @@ -183,18 +212,47 @@ class PuterMenubar extends PuterWebComponent { // dropdown — is treated as a toggle-close. Uses composedPath because // the button lives inside this shadow root. this._docPointerDownHandler = (e) => { - if ( ! this.#activeButtonEl ) return; const path = typeof e.composedPath === 'function' ? e.composedPath() : []; - if ( path.includes(this.#activeButtonEl) ) { + if ( this.#activeButtonEl && path.includes(this.#activeButtonEl) ) { this._suppressClickFor = this.#activeButtonEl; clearTimeout(this._suppressClickTimer); this._suppressClickTimer = setTimeout(() => { this._suppressClickFor = null; }, 400); } + // If the click lands fully outside the menubar host, drop any + // residual keyboard state. Without this, an Alt-activated menubar + // would keep intercepting arrow keys typed into other inputs. + // When a dropdown is open, its own close event will deactivate + // the menubar — leave that path alone here. + if ( this.#menubarActive && ! this.#activeDropdown && ! path.includes(this) ) { + this._deactivateMenubar(); + } }; document.addEventListener('pointerdown', this._docPointerDownHandler, true); + // Focus moving to an element outside the menubar (e.g. a text input) + // should also drop menubar state so global key handling stops. + this._docFocusInHandler = (e) => { + if ( ! this.#menubarActive ) return; + if ( this.#activeDropdown ) return; + const path = typeof e.composedPath === 'function' ? e.composedPath() : []; + if ( ! path.includes(this) ) { + this._deactivateMenubar(); + } + }; + document.addEventListener('focusin', this._docFocusInHandler, true); + + // Window losing focus (alt-tab, devtools, etc.) — reset state. + this._winBlurHandler = () => { + if ( this.#menubarActive && ! this.#activeDropdown ) { + this._deactivateMenubar(); + } + this.#altDown = false; + this.#altConsumed = false; + }; + window.addEventListener('blur', this._winBlurHandler); + // Once the user actually moves the mouse, exit keyboard-nav mode so // :hover styling on menubar buttons works normally again. this._mouseMoveHandler = () => this._setKeyboardNav(false); @@ -229,10 +287,10 @@ class PuterMenubar extends PuterWebComponent { switch ( e.key ) { case 'ArrowRight': - this._moveButtonFocus(+1); + this._moveButtonFocus(+1, { openDropdown: true }); break; case 'ArrowLeft': - this._moveButtonFocus(-1); + this._moveButtonFocus(-1, { openDropdown: true }); break; case 'ArrowDown': case 'Enter': @@ -297,7 +355,7 @@ class PuterMenubar extends PuterWebComponent { if ( menubar ) menubar.classList.toggle('keyboard-nav', on); } - _moveButtonFocus (delta, { swapDropdown = true } = {}) { + _moveButtonFocus (delta, { swapDropdown = true, openDropdown = false } = {}) { if ( ! this.#items.length ) return; const n = this.#items.length; const cur = this.#focusedIndex == null ? (delta > 0 ? -1 : 0) : this.#focusedIndex; @@ -306,11 +364,21 @@ class PuterMenubar extends PuterWebComponent { this._renderButtonFocus(); this._setKeyboardNav(true); + const btn = this._buttonEl(next); + const item = this.#items[next]; + if ( ! btn || ! item ) return; + // If a dropdown is already open, swap to the new button's dropdown if ( swapDropdown && this.#activeDropdown ) { - const btn = this._buttonEl(next); - const item = this.#items[next]; - if ( btn && item ) this._openDropdown(btn, item); + this._openDropdown(btn, item); + return; + } + + // Arrow-nav at the menubar level opens the focused button's dropdown + // but does NOT pre-focus the first item — let the user press ArrowDown + // to enter the menu. + if ( openDropdown ) { + this._openFocusedButton(false); } } @@ -377,9 +445,8 @@ class PuterMenubar extends PuterWebComponent { } }); // Keyboard navigate request bubbling from the context menu. - // Arrow-left/right at the menubar level closes the current dropdown - // and moves button focus only — the user must press ArrowDown (or - // Enter/Space) to open the adjacent dropdown. + // Arrow-left/right closes the current dropdown and opens the adjacent + // button's dropdown, mirroring menubar-level arrow nav. // Arrow-up at the dropdown's first item closes it and returns // focus to the same menubar button (which can re-open with ArrowDown). dropdown.addEventListener('puter-menu-navigate', (e) => { @@ -392,7 +459,7 @@ class PuterMenubar extends PuterWebComponent { } const delta = e.detail.direction === 'right' ? +1 : -1; this._closeDropdown(); - this._moveButtonFocus(delta, { swapDropdown: false }); + this._moveButtonFocus(delta, { swapDropdown: false, openDropdown: true }); }); document.body.appendChild(dropdown); @@ -427,6 +494,12 @@ class PuterMenubar extends PuterWebComponent { if ( this._docPointerDownHandler ) { document.removeEventListener('pointerdown', this._docPointerDownHandler, true); } + if ( this._docFocusInHandler ) { + document.removeEventListener('focusin', this._docFocusInHandler, true); + } + if ( this._winBlurHandler ) { + window.removeEventListener('blur', this._winBlurHandler); + } if ( this._mouseMoveHandler ) { document.removeEventListener('mousemove', this._mouseMoveHandler); }