mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 12:50:59 +00:00
Merge pull request #3114 from HeyPuter/puter-js-update-260514
Puter JS MenuBar Web Component improvements
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user