- Also fixes similar issues with navigating top level menubar items and submenus
This commit is contained in:
Miika Kuisma
2026-05-13 09:27:44 +03:00
parent 5a2071f852
commit f63c7d8142
2 changed files with 67 additions and 7 deletions
@@ -709,9 +709,24 @@ class PuterContextMenu extends PuterWebComponent {
case 'ArrowDown':
this._moveFocus(+1);
return true;
case 'ArrowUp':
case 'ArrowUp': {
// Root dropdown: ArrowUp on the first focusable item bubbles
// to the menubar, which closes the dropdown and re-focuses
// the parent menubar button.
if ( ! this._parentMenu ) {
const f = this._focusableIndices();
if ( f.length && this.#focusedIndex === f[0] ) {
this.dispatchEvent(new CustomEvent('puter-menu-navigate', {
detail: { direction: 'up' },
bubbles: true,
composed: true,
}));
return true;
}
}
this._moveFocus(-1);
return true;
}
case 'Home': {
const f = this._focusableIndices();
if ( f.length ) this._setFocusIndex(f[0]);
@@ -822,14 +837,21 @@ class PuterContextMenu extends PuterWebComponent {
if ( ! el ) return false;
clearTimeout(this.#submenuTimeout);
this._cancelSubmenuClose();
this._showSubmenu(el, item.items);
// Focus first item in submenu, and start it in keyboard-nav mode so a
// stale cursor position doesn't fight the keyboard focus.
// Don't re-open an already-open submenu for this parent — it would
// wipe the user's focused item inside it. We only auto-focus the
// first sub-item when the submenu is freshly opened by this call.
const wasNewlyOpened = !this.#activeSubmenu || this.#activeSubmenu.parentEl !== el;
if ( wasNewlyOpened ) {
this._showSubmenu(el, item.items);
}
requestAnimationFrame(() => {
const sub = this.#activeSubmenu && this.#activeSubmenu.element;
if ( sub ) {
if ( ! sub ) return;
if ( wasNewlyOpened ) {
const f = sub._focusableIndices();
if ( f.length ) sub._setFocusIndex(f[0]);
}
if ( typeof sub._setKeyboardNav === 'function' ) {
sub._setKeyboardNav(true);
}
});
+40 -2
View File
@@ -68,6 +68,11 @@ class PuterMenubar extends PuterWebComponent {
.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;
}
.menu-button:focus { outline: none; }
@media (max-width: 480px) {
.menubar {
@@ -99,6 +104,9 @@ class PuterMenubar extends PuterWebComponent {
.menu-button.focused {
background-color: #3a3a3a;
}
.menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) {
background-color: transparent;
}
}
`;
}
@@ -122,6 +130,9 @@ class PuterMenubar extends PuterWebComponent {
if ( this._docPointerDownHandler ) {
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
}
if ( this._mouseMoveHandler ) {
document.removeEventListener('mousemove', this._mouseMoveHandler);
}
const buttons = this.$$('.menu-button');
buttons.forEach((btn) => {
@@ -182,6 +193,11 @@ class PuterMenubar extends PuterWebComponent {
}
};
document.addEventListener('pointerdown', this._docPointerDownHandler, true);
// 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);
document.addEventListener('mousemove', this._mouseMoveHandler);
}
_onGlobalKeyDown (e) {
@@ -258,12 +274,14 @@ class PuterMenubar extends PuterWebComponent {
this.#menubarActive = true;
this.#focusedIndex = 0;
this._renderButtonFocus();
this._setKeyboardNav(true);
}
_deactivateMenubar () {
this.#menubarActive = false;
this.#focusedIndex = null;
this._renderButtonFocus();
this._setKeyboardNav(false);
}
_renderButtonFocus () {
@@ -273,6 +291,11 @@ class PuterMenubar extends PuterWebComponent {
});
}
_setKeyboardNav (on) {
const menubar = this.$('.menubar');
if ( menubar ) menubar.classList.toggle('keyboard-nav', on);
}
_moveButtonFocus (delta, { swapDropdown = true } = {}) {
if ( ! this.#items.length ) return;
const n = this.#items.length;
@@ -280,6 +303,7 @@ class PuterMenubar extends PuterWebComponent {
const next = (cur + delta + n) % n;
this.#focusedIndex = next;
this._renderButtonFocus();
this._setKeyboardNav(true);
// If a dropdown is already open, swap to the new button's dropdown
if ( swapDropdown && this.#activeDropdown ) {
@@ -348,12 +372,23 @@ class PuterMenubar extends PuterWebComponent {
this._deactivateMenubar();
}
});
// Keyboard navigate request bubbling from the context menu
// 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-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) => {
if ( ! e.detail ) return;
if ( e.detail.direction === 'up' ) {
this._closeDropdown();
this._renderButtonFocus();
this._setKeyboardNav(true);
return;
}
const delta = e.detail.direction === 'right' ? +1 : -1;
this._closeDropdown();
this._moveButtonFocus(delta, { swapDropdown: false });
this._openFocusedButton(true);
});
document.body.appendChild(dropdown);
@@ -387,6 +422,9 @@ class PuterMenubar extends PuterWebComponent {
if ( this._docPointerDownHandler ) {
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
}
if ( this._mouseMoveHandler ) {
document.removeEventListener('mousemove', this._mouseMoveHandler);
}
}
_escapeHTML (str) {