Merge pull request #3114 from HeyPuter/puter-js-update-260514

Puter JS MenuBar Web Component improvements
This commit is contained in:
Miika Kuisma
2026-05-14 09:14:19 +03:00
committed by GitHub
2 changed files with 144 additions and 33 deletions
@@ -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 <body>, 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);
}
+97 -24
View File
@@ -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);
}